Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07b357de04 | |||
| ddbd119dcb | |||
| bd41dac88f | |||
| 41f6daf96a | |||
| 5381795844 | |||
| 7b9d7a7b7d | |||
| 7b0fd9fed2 | |||
| b4a01cc3cd | |||
| a6e1aa085f | |||
| 6a9cbe7de0 | |||
| 0e17c4f299 | |||
| 7c121d48b9 | |||
| f5a8202b41 | |||
| 43c24d8d0f | |||
| 09603ed52f | |||
| 38e3b4087b | |||
| eb52362e96 | |||
| 2969a513ce | |||
| de672fd214 | |||
| d0779d2aca | |||
| 650594e801 | |||
| 58143c4b07 | |||
| 4cbeacbc76 | |||
| b0ca0419be | |||
| 4308dd75fb | |||
| 7690481fc1 | |||
| ec6d42d41d | |||
| 3553754083 | |||
| ed839846d1 | |||
| ba803dd89a | |||
| 0e118d1961 | |||
| 40e73c4910 | |||
| 9559e2425b | |||
| ac76af8c56 | |||
| b67b03e0ae | |||
| ef82426e28 | |||
| 14e9e5d9d6 | |||
| 9324ef0d08 | |||
| 8940572098 | |||
| 17d66ee4fe | |||
| 13006d6d7c | |||
| 539b118690 | |||
| 11f7e5a1b0 | |||
| 38b406fb35 | |||
| 4702cddb3e | |||
| 82c5d45601 | |||
| 66d409d679 | |||
| 4ad261d8a7 | |||
| 59c99dc62d | |||
| a8f7c5ec93 | |||
| 34cae2f3cb | |||
| e94d46af86 | |||
| c035c35eba | |||
| b70b45964a | |||
| e3c8d221ec | |||
| 80e5fb11c2 | |||
| bc6c4c72ab | |||
| 4db2746e31 | |||
| 71e90073d2 | |||
| ea27114eb9 | |||
| 64d67f2134 | |||
| a99337fbe1 | |||
| b20b569b0e | |||
| c449d3dc74 | |||
| e4eb98b991 | |||
| b32f071502 | |||
| 512e34af83 | |||
| 7051796c38 | |||
| b67f5d741f | |||
| eec0843ce4 | |||
| 55baa16fbc | |||
| c79a9634d3 | |||
| 8dd6448c90 | |||
| 18b9cec50d | |||
| 2932a7a35d | |||
| 922b2e10eb | |||
| a2ee437a0e | |||
| 6618e2bce2 | |||
| cb15b3ad84 | |||
| ee16f08ffa | |||
| 9790a61f96 | |||
| 2339aacbf2 | |||
| 2d348da504 | |||
| cbd2620c46 | |||
| 3bd304796b | |||
| dac81cdb68 | |||
| 2c3bcf3e41 | |||
| d9d43d8519 | |||
| bcc69f0afb | |||
| e53563f402 | |||
| 7e4b02f1bf | |||
| 996928b32a | |||
| c032e821bc | |||
| 6f160bb48f | |||
| ebb672ac39 | |||
| 9a19e84265 | |||
| 338666d13e | |||
| 7b8a1037a0 | |||
| ee5cf45ef9 | |||
| 2e1593dea5 | |||
| 0f3d168fdd | |||
| ce09fc8356 | |||
| 44a35c5895 | |||
| 8a321c4536 | |||
| ef9e567e5f | |||
| b396b71c6f | |||
| d8efc575fa | |||
| e1fbed8fb6 | |||
| 12ae22378f | |||
| a88b436eec | |||
| dbe36851bc | |||
| ff9d7cab5c | |||
| 88681d389b | |||
| ae2ecd1ed3 | |||
| 159d271e1e | |||
| 762850dfe5 | |||
| b039702e8c | |||
| 094886c848 | |||
| dc0297f4ac | |||
| 3ab67f3280 | |||
| a2e6bd503b | |||
| 05ac345696 | |||
| e3feca09b3 | |||
| 896ad7b884 | |||
| 0a945219a9 | |||
| 94828eb44b | |||
| 96192495ae | |||
| f7b5576bcc | |||
| cb35493242 | |||
| 5bfd7fd16c | |||
| 2932b41e64 | |||
| eb389c58eb | |||
| 54ff0a669b | |||
| e56999fd36 | |||
| 1124315267 | |||
| 2bf3f3041f | |||
| 6e4db5666a | |||
| 564cde393e | |||
| c813927bb6 | |||
| 5970c12d90 | |||
| 116a4e33ba | |||
| 611e48c4ac | |||
| 836a33198e | |||
| 7afd477d1a | |||
| fe143df151 | |||
| e94aecaa08 | |||
| 418c0ea04a | |||
| 4312e5df05 | |||
| f80f3e33ec | |||
| 479206449e | |||
| 6849059b00 | |||
| 43df145cd2 | |||
| 28a0bf6c8f | |||
| 30e4edffd5 | |||
| 5452ab6db7 | |||
| e85119aa64 | |||
| 71b27a1b0f | |||
| 32f37d86f0 | |||
| b14ea406ba | |||
| de846ca57d | |||
| f06b78751e | |||
| 969d0f48b2 | |||
| fc19dcc70c | |||
| 49c6b46afc | |||
| f97e115ee2 | |||
| e92b1fe7d7 | |||
| f98449c9b5 | |||
| 468eb68878 | |||
| 23b594de6e | |||
| 5060577ee1 | |||
| d3ebb1f7c0 | |||
| e4cc4e1682 | |||
| 53e89f9d52 | |||
| de247b7aae | |||
| 0b8050d453 | |||
| be6a89a3b8 | |||
| c0a8b509c7 | |||
| f80651fa91 | |||
| 68af95390d | |||
| 321db7a819 | |||
| 6d2219e001 | |||
| dd432e3cde | |||
| 77db212a0a | |||
| f56263791c | |||
| 88363f1ed9 | |||
| c5db39f626 | |||
| b5aed287ca | |||
| 53849bd866 | |||
| e33912bfee | |||
| 548648a3d9 | |||
| 4643e13170 |
@@ -14,3 +14,4 @@ rekram1-node
|
||||
thdxr
|
||||
simonklee
|
||||
vimtor
|
||||
starptech
|
||||
|
||||
@@ -4,8 +4,8 @@ import { tool } from "@opencode-ai/plugin"
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
desktop_web: ["Hona", "Brendonovich"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||
inference: ["fwang", "MrMushrooooom"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
|
||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||
|
||||
## Commits and PR Titles
|
||||
|
||||
Use conventional commit-style messages and PR titles: `type(scope): summary`.
|
||||
|
||||
Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`.
|
||||
|
||||
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
||||
|
||||
## Style Guide
|
||||
|
||||
### General Principles
|
||||
|
||||
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
اذا كنت تعمل على مشروع مرتبط بـ OpenCode ويستخدم "opencode" كجزء من اسمه (مثل "opencode-dashboard" او "opencode-mobile")، يرجى اضافة ملاحظة في README توضح انه ليس مبنيا بواسطة فريق OpenCode ولا يرتبط بنا بأي شكل.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### ما الفرق عن Claude Code؟
|
||||
|
||||
هو مشابه جدا لـ Claude Code من حيث القدرات. هذه هي الفروقات الاساسية:
|
||||
|
||||
- 100% مفتوح المصدر
|
||||
- غير مقترن بمزود معين. نوصي بالنماذج التي نوفرها عبر [OpenCode Zen](https://opencode.ai/zen)؛ لكن يمكن استخدام OpenCode مع Claude او OpenAI او Google او حتى نماذج محلية. مع تطور النماذج ستتقلص الفجوات وستنخفض الاسعار، لذا من المهم ان يكون مستقلا عن المزود.
|
||||
- دعم LSP جاهز للاستخدام
|
||||
- تركيز على TUI. تم بناء OpenCode بواسطة مستخدمي neovim ومنشئي [terminal.shop](https://terminal.shop)؛ وسندفع حدود ما هو ممكن داخل الطرفية.
|
||||
- معمارية عميل/خادم. على سبيل المثال، يمكن تشغيل OpenCode على جهازك بينما تقوده عن بعد من تطبيق جوال. هذا يعني ان واجهة TUI هي واحدة فقط من العملاء الممكنين.
|
||||
|
||||
---
|
||||
|
||||
**انضم الى مجتمعنا** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়ে
|
||||
|
||||
আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই।
|
||||
|
||||
### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
|
||||
|
||||
#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা?
|
||||
|
||||
ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো:
|
||||
|
||||
- ১০০% ওপেন সোর্স
|
||||
- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [OpenCode Zen](https://opencode.ai/zen) এর মাধ্যমে মডেলসমূহ ব্যবহারের পরামর্শ দিই, OpenCode ক্লড (Claude), ওপেনএআই (OpenAI), গুগল (Google), অথবা লোকাল মডেলগুলোর সাথেও ব্যবহার করা যেতে পারে। যেমন যেমন মডেলগুলো উন্নত হবে, তাদের মধ্যকার পার্থক্য কমে আসবে এবং দামও কমবে, তাই প্রোভাইডার-অজ্ঞাস্টিক হওয়া খুবই গুরুত্বপূর্ণ।
|
||||
- আউট-অফ-দ্য-বক্স LSP সাপোর্ট
|
||||
- TUI এর উপর ফোকাস। OpenCode নিওভিম (neovim) ব্যবহারকারী এবং [terminal.shop](https://terminal.shop) এর নির্মাতাদের দ্বারা তৈরি; আমরা টার্মিনালে কী কী সম্ভব তার সীমাবদ্ধতা ছাড়িয়ে যাওয়ার চেষ্টা করছি।
|
||||
- ক্লায়েন্ট/সার্ভার আর্কিটেকচার। এটি যেমন OpenCode কে আপনার কম্পিউটারে চালানোর সুযোগ দেয়, তেমনি আপনি মোবাইল অ্যাপ থেকে রিমোটলি এটি নিয়ন্ত্রণ করতে পারবেন, অর্থাৎ TUI ফ্রন্টএন্ড কেবল সম্ভাব্য ক্লায়েন্টগুলোর মধ্যে একটি।
|
||||
|
||||
---
|
||||
|
||||
**আমাদের কমিউনিটিতে যুক্ত হোন** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Se você tem interesse em contribuir com o OpenCode, leia os [contributing docs]
|
||||
|
||||
Se você estiver trabalhando em um projeto relacionado ao OpenCode e estiver usando "opencode" como parte do nome (por exemplo, "opencode-dashboard" ou "opencode-mobile"), adicione uma nota no README para deixar claro que não foi construído pela equipe do OpenCode e não é afiliado a nós de nenhuma forma.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Como isso é diferente do Claude Code?
|
||||
|
||||
É muito parecido com o Claude Code em termos de capacidade. Aqui estão as principais diferenças:
|
||||
|
||||
- 100% open source
|
||||
- Não está acoplado a nenhum provedor. Embora recomendemos os modelos que oferecemos pelo [OpenCode Zen](https://opencode.ai/zen); o OpenCode pode ser usado com Claude, OpenAI, Google ou até modelos locais. À medida que os modelos evoluem, as diferenças diminuem e os preços caem, então ser provider-agnostic é importante.
|
||||
- Suporte a LSP pronto para uso
|
||||
- Foco em TUI. O OpenCode é construído por usuários de neovim e pelos criadores do [terminal.shop](https://terminal.shop); vamos levar ao limite o que é possível no terminal.
|
||||
- Arquitetura cliente/servidor. Isso, por exemplo, permite executar o OpenCode no seu computador enquanto você o controla remotamente por um aplicativo mobile. Isso significa que o frontend TUI é apenas um dos possíveis clientes.
|
||||
|
||||
---
|
||||
|
||||
**Junte-se à nossa comunidade** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIB
|
||||
|
||||
Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Po čemu se razlikuje od Claude Code-a?
|
||||
|
||||
Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
|
||||
|
||||
- 100% open source
|
||||
- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
|
||||
- LSP podrška odmah po instalaciji
|
||||
- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
|
||||
- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
|
||||
|
||||
---
|
||||
|
||||
**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Hvis du vil bidrage til OpenCode, så læs vores [contributing docs](./CONTRIBUT
|
||||
|
||||
Hvis du arbejder på et projekt der er relateret til OpenCode og bruger "opencode" som en del af navnet; f.eks. "opencode-dashboard" eller "opencode-mobile", så tilføj en note i din README, der tydeliggør at projektet ikke er bygget af OpenCode-teamet og ikke er tilknyttet os på nogen måde.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Hvordan adskiller dette sig fra Claude Code?
|
||||
|
||||
Det minder meget om Claude Code i forhold til funktionalitet. Her er de vigtigste forskelle:
|
||||
|
||||
- 100% open source
|
||||
- Ikke låst til en udbyder. Selvom vi anbefaler modellerne via [OpenCode Zen](https://opencode.ai/zen); kan OpenCode bruges med Claude, OpenAI, Google eller endda lokale modeller. Efterhånden som modeller udvikler sig vil forskellene mindskes og priserne falde, så det er vigtigt at være provider-agnostic.
|
||||
- LSP-support out of the box
|
||||
- Fokus på TUI. OpenCode er bygget af neovim-brugere og skaberne af [terminal.shop](https://terminal.shop); vi vil skubbe grænserne for hvad der er muligt i terminalen.
|
||||
- Klient/server-arkitektur. Det kan f.eks. lade OpenCode køre på din computer, mens du styrer den eksternt fra en mobilapp. Det betyder at TUI-frontend'en kun er en af de mulige clients.
|
||||
|
||||
---
|
||||
|
||||
**Bliv en del af vores community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Wenn du zu OpenCode beitragen möchtest, lies bitte unsere [Contributing Docs](.
|
||||
|
||||
Wenn du an einem Projekt arbeitest, das mit OpenCode zusammenhängt und "opencode" als Teil seines Namens verwendet (z.B. "opencode-dashboard" oder "opencode-mobile"), füge bitte einen Hinweis in deine README ein, dass es nicht vom OpenCode-Team gebaut wird und nicht in irgendeiner Weise mit uns verbunden ist.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Worin unterscheidet sich das von Claude Code?
|
||||
|
||||
In Bezug auf die Fähigkeiten ist es Claude Code sehr ähnlich. Hier sind die wichtigsten Unterschiede:
|
||||
|
||||
- 100% open source
|
||||
- Nicht an einen Anbieter gekoppelt. Wir empfehlen die Modelle aus [OpenCode Zen](https://opencode.ai/zen); OpenCode kann aber auch mit Claude, OpenAI, Google oder sogar lokalen Modellen genutzt werden. Mit der Weiterentwicklung der Modelle werden die Unterschiede kleiner und die Preise sinken, deshalb ist Provider-Unabhängigkeit wichtig.
|
||||
- LSP-Unterstützung direkt nach dem Start
|
||||
- Fokus auf TUI. OpenCode wird von Neovim-Nutzern und den Machern von [terminal.shop](https://terminal.shop) gebaut; wir treiben die Grenzen dessen, was im Terminal möglich ist.
|
||||
- Client/Server-Architektur. Das ermöglicht z.B., OpenCode auf deinem Computer laufen zu lassen, während du es von einer mobilen App aus fernsteuerst. Das TUI-Frontend ist nur einer der möglichen Clients.
|
||||
|
||||
---
|
||||
|
||||
**Tritt unserer Community bei** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
+9
-21
@@ -97,20 +97,20 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Agents
|
||||
### Agentes
|
||||
|
||||
OpenCode incluye dos agents integrados que puedes alternar con la tecla `Tab`.
|
||||
OpenCode incluye dos agentes integrados que puedes alternar con la tecla `Tab`.
|
||||
|
||||
- **build** - Por defecto, agent con acceso completo para trabajo de desarrollo
|
||||
- **plan** - Agent de solo lectura para análisis y exploración de código
|
||||
- Niega ediciones de archivos por defecto
|
||||
- **build** - Por defecto, agente con acceso completo para tareas de desarrollo
|
||||
- **plan** - Agente de solo lectura para análisis y exploración de código
|
||||
- Deniega ediciones de archivos por defecto
|
||||
- Pide permiso antes de ejecutar comandos bash
|
||||
- Ideal para explorar codebases desconocidas o planificar cambios
|
||||
|
||||
Además, incluye un subagent **general** para búsquedas complejas y tareas de varios pasos.
|
||||
Además, incluye un subagente **general** para búsquedas complejas y tareas de varios pasos.
|
||||
Se usa internamente y se puede invocar con `@general` en los mensajes.
|
||||
|
||||
Más información sobre [agents](https://opencode.ai/docs/agents).
|
||||
Más información sobre [agentes](https://opencode.ai/docs/agents).
|
||||
|
||||
### Documentación
|
||||
|
||||
@@ -120,21 +120,9 @@ Para más información sobre cómo configurar OpenCode, [**ve a nuestra document
|
||||
|
||||
Si te interesa contribuir a OpenCode, lee nuestras [docs de contribución](./CONTRIBUTING.md) antes de enviar un pull request.
|
||||
|
||||
### Construyendo sobre OpenCode
|
||||
### Proyectos basados en OpenCode
|
||||
|
||||
Si estás trabajando en un proyecto relacionado con OpenCode y usas "opencode" como parte del nombre; por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está construido por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### ¿En qué se diferencia de Claude Code?
|
||||
|
||||
Es muy similar a Claude Code en cuanto a capacidades. Estas son las diferencias clave:
|
||||
|
||||
- 100% open source
|
||||
- No está acoplado a ningún proveedor. Aunque recomendamos los modelos que ofrecemos a través de [OpenCode Zen](https://opencode.ai/zen); OpenCode se puede usar con Claude, OpenAI, Google o incluso modelos locales. A medida que evolucionan los modelos, las brechas se cerrarán y los precios bajarán, por lo que ser agnóstico al proveedor es importante.
|
||||
- Soporte LSP listo para usar
|
||||
- Un enfoque en la TUI. OpenCode está construido por usuarios de neovim y los creadores de [terminal.shop](https://terminal.shop); vamos a empujar los límites de lo que es posible en la terminal.
|
||||
- Arquitectura cliente/servidor. Esto, por ejemplo, permite ejecutar OpenCode en tu computadora mientras lo controlas de forma remota desde una app móvil. Esto significa que el frontend TUI es solo uno de los posibles clientes.
|
||||
Si estás trabajando en un proyecto basado en OpenCode y usas "opencode" como parte del nombre, por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está hecho por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -124,18 +124,6 @@ Si vous souhaitez contribuer à OpenCode, lisez nos [docs de contribution](./CON
|
||||
|
||||
Si vous travaillez sur un projet lié à OpenCode et que vous utilisez "opencode" dans le nom du projet (par exemple, "opencode-dashboard" ou "opencode-mobile"), ajoutez une note dans votre README pour préciser qu'il n'est pas construit par l'équipe OpenCode et qu'il n'est pas affilié à nous.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### En quoi est-ce différent de Claude Code ?
|
||||
|
||||
C'est très similaire à Claude Code en termes de capacités. Voici les principales différences :
|
||||
|
||||
- 100% open source
|
||||
- Pas couplé à un fournisseur. Nous recommandons les modèles proposés via [OpenCode Zen](https://opencode.ai/zen) ; OpenCode peut être utilisé avec Claude, OpenAI, Google ou même des modèles locaux. Au fur et à mesure que les modèles évoluent, les écarts se réduiront et les prix baisseront, donc être agnostique au fournisseur est important.
|
||||
- Support LSP prêt à l'emploi
|
||||
- Un focus sur la TUI. OpenCode est construit par des utilisateurs de neovim et les créateurs de [terminal.shop](https://terminal.shop) ; nous allons repousser les limites de ce qui est possible dans le terminal.
|
||||
- Architecture client/serveur. Cela permet par exemple de faire tourner OpenCode sur votre ordinateur tout en le pilotant à distance depuis une application mobile. Cela signifie que la TUI n'est qu'un des clients possibles.
|
||||
|
||||
---
|
||||
|
||||
**Rejoignez notre communauté** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς.
|
||||
|
||||
### Συχνές Ερωτήσεις
|
||||
|
||||
#### Πώς διαφέρει αυτό από το Claude Code;
|
||||
|
||||
Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές:
|
||||
|
||||
- 100% ανοιχτού κώδικα
|
||||
- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο.
|
||||
- Out-of-the-box υποστήριξη LSP
|
||||
- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal.
|
||||
- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients.
|
||||
|
||||
---
|
||||
|
||||
**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Se sei interessato a contribuire a OpenCode, leggi la nostra [guida alla contrib
|
||||
|
||||
Se stai lavorando a un progetto correlato a OpenCode e che utilizza “opencode” come parte del nome (ad esempio “opencode-dashboard” o “opencode-mobile”), aggiungi una nota nel tuo README per chiarire che non è sviluppato dal team OpenCode e che non è affiliato in alcun modo con noi.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### In cosa è diverso da Claude Code?
|
||||
|
||||
È molto simile a Claude Code in termini di funzionalità. Ecco le principali differenze:
|
||||
|
||||
- 100% open source
|
||||
- Non è legato a nessun provider. Anche se consigliamo i modelli forniti tramite [OpenCode Zen](https://opencode.ai/zen), OpenCode può essere utilizzato con Claude, OpenAI, Google o persino modelli locali. Con l’evoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante.
|
||||
- Supporto LSP pronto all’uso
|
||||
- Forte attenzione alla TUI. OpenCode è sviluppato da utenti neovim e dai creatori di [terminal.shop](https://terminal.shop); spingeremo al limite ciò che è possibile fare nel terminale.
|
||||
- Architettura client/server. Questo, ad esempio, permette a OpenCode di girare sul tuo computer mentre lo controlli da remoto tramite un’app mobile. La frontend TUI è quindi solo uno dei possibili client.
|
||||
|
||||
---
|
||||
|
||||
**Unisciti alla nostra community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode に貢献したい場合は、Pull Request を送る前に [contributin
|
||||
|
||||
OpenCode に関連するプロジェクトで、名前に "opencode"(例: "opencode-dashboard" や "opencode-mobile")を含める場合は、そのプロジェクトが OpenCode チームによって作られたものではなく、いかなる形でも関係がないことを README に明記してください。
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Claude Code との違いは?
|
||||
|
||||
機能面では Claude Code と非常に似ています。主な違いは次のとおりです。
|
||||
|
||||
- 100% オープンソース
|
||||
- 特定のプロバイダーに依存しません。[OpenCode Zen](https://opencode.ai/zen) で提供しているモデルを推奨しますが、OpenCode は Claude、OpenAI、Google、またはローカルモデルでも利用できます。モデルが進化すると差は縮まり価格も下がるため、provider-agnostic であることが重要です。
|
||||
- そのまま使える LSP サポート
|
||||
- TUI にフォーカス。OpenCode は neovim ユーザーと [terminal.shop](https://terminal.shop) の制作者によって作られており、ターミナルで可能なことの限界を押し広げます。
|
||||
- クライアント/サーバー構成。例えば OpenCode をあなたのPCで動かし、モバイルアプリからリモート操作できます。TUI フロントエンドは複数あるクライアントの1つにすぎません。
|
||||
|
||||
---
|
||||
|
||||
**コミュニティに参加** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode 에 기여하고 싶다면, Pull Request 를 제출하기 전에 [contr
|
||||
|
||||
OpenCode 와 관련된 프로젝트를 진행하면서 이름에 "opencode"(예: "opencode-dashboard" 또는 "opencode-mobile") 를 포함한다면, README 에 해당 프로젝트가 OpenCode 팀이 만든 것이 아니며 어떤 방식으로도 우리와 제휴되어 있지 않다는 점을 명시해 주세요.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Claude Code 와는 무엇이 다른가요?
|
||||
|
||||
기능 면에서는 Claude Code 와 매우 유사합니다. 주요 차이점은 다음과 같습니다.
|
||||
|
||||
- 100% 오픈 소스
|
||||
- 특정 제공자에 묶여 있지 않습니다. [OpenCode Zen](https://opencode.ai/zen) 을 통해 제공하는 모델을 권장하지만, OpenCode 는 Claude, OpenAI, Google 또는 로컬 모델과도 사용할 수 있습니다. 모델이 발전하면서 격차는 줄고 가격은 내려가므로 provider-agnostic 인 것이 중요합니다.
|
||||
- 기본으로 제공되는 LSP 지원
|
||||
- TUI 에 집중. OpenCode 는 neovim 사용자와 [terminal.shop](https://terminal.shop) 제작자가 만들었으며, 터미널에서 가능한 것의 한계를 밀어붙입니다.
|
||||
- 클라이언트/서버 아키텍처. 예를 들어 OpenCode 를 내 컴퓨터에서 실행하면서 모바일 앱으로 원격 조작할 수 있습니다. 즉, TUI 프런트엔드는 가능한 여러 클라이언트 중 하나일 뿐입니다.
|
||||
|
||||
---
|
||||
|
||||
**커뮤니티에 참여하기** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ If you're interested in contributing to OpenCode, please read our [contributing
|
||||
|
||||
If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||
- Built-in opt-in LSP support
|
||||
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
---
|
||||
|
||||
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Hvis du vil bidra til OpenCode, les [contributing docs](./CONTRIBUTING.md) før
|
||||
|
||||
Hvis du jobber med et prosjekt som er relatert til OpenCode og bruker "opencode" som en del av navnet; for eksempel "opencode-dashboard" eller "opencode-mobile", legg inn en merknad i README som presiserer at det ikke er bygget av OpenCode-teamet og ikke er tilknyttet oss på noen måte.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Hvordan er dette forskjellig fra Claude Code?
|
||||
|
||||
Det er veldig likt Claude Code når det gjelder funksjonalitet. Her er de viktigste forskjellene:
|
||||
|
||||
- 100% open source
|
||||
- Ikke knyttet til en bestemt leverandør. Selv om vi anbefaler modellene vi tilbyr gjennom [OpenCode Zen](https://opencode.ai/zen); kan OpenCode brukes med Claude, OpenAI, Google eller til og med lokale modeller. Etter hvert som modellene utvikler seg vil gapene lukkes og prisene gå ned, så det er viktig å være provider-agnostic.
|
||||
- LSP-støtte rett ut av boksen
|
||||
- Fokus på TUI. OpenCode er bygget av neovim-brukere og skaperne av [terminal.shop](https://terminal.shop); vi kommer til å presse grensene for hva som er mulig i terminalen.
|
||||
- Klient/server-arkitektur. Dette kan for eksempel la OpenCode kjøre på maskinen din, mens du styrer den eksternt fra en mobilapp. Det betyr at TUI-frontend'en bare er en av de mulige klientene.
|
||||
|
||||
---
|
||||
|
||||
**Bli med i fellesskapet** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Jeśli chcesz współtworzyć OpenCode, przeczytaj [contributing docs](./CONTRIB
|
||||
|
||||
Jeśli pracujesz nad projektem związanym z OpenCode i używasz "opencode" jako części nazwy (na przykład "opencode-dashboard" lub "opencode-mobile"), dodaj proszę notatkę do swojego README, aby wyjaśnić, że projekt nie jest tworzony przez zespół OpenCode i nie jest z nami w żaden sposób powiązany.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Czym to się różni od Claude Code?
|
||||
|
||||
Jest bardzo podobne do Claude Code pod względem możliwości. Oto kluczowe różnice:
|
||||
|
||||
- 100% open source
|
||||
- Niezależne od dostawcy. Chociaż polecamy modele oferowane przez [OpenCode Zen](https://opencode.ai/zen); OpenCode może być używany z Claude, OpenAI, Google, a nawet z modelami lokalnymi. W miarę jak modele ewoluują, różnice będą się zmniejszać, a ceny spadać, więc ważna jest niezależność od dostawcy.
|
||||
- Wbudowane wsparcie LSP
|
||||
- Skupienie na TUI. OpenCode jest budowany przez użytkowników neovim i twórców [terminal.shop](https://terminal.shop); przesuwamy granice tego, co jest możliwe w terminalu.
|
||||
- Architektura klient/serwer. Pozwala np. uruchomić OpenCode na twoim komputerze, a sterować nim zdalnie z aplikacji mobilnej. To znaczy, że frontend TUI jest tylko jednym z możliwych klientów.
|
||||
|
||||
---
|
||||
|
||||
**Dołącz do naszej społeczności** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
Если вы делаете проект, связанный с OpenCode, и используете "opencode" как часть имени (например, "opencode-dashboard" или "opencode-mobile"), добавьте примечание в README, чтобы уточнить, что проект не создан командой OpenCode и не аффилирован с нами.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Чем это отличается от Claude Code?
|
||||
|
||||
По возможностям это очень похоже на Claude Code. Вот ключевые отличия:
|
||||
|
||||
- 100% open source
|
||||
- Не привязано к одному провайдеру. Мы рекомендуем модели из [OpenCode Zen](https://opencode.ai/zen); но OpenCode можно использовать с Claude, OpenAI, Google или даже локальными моделями. По мере развития моделей разрыв будет сокращаться, а цены падать, поэтому важна независимость от провайдера.
|
||||
- Поддержка LSP из коробки
|
||||
- Фокус на TUI. OpenCode построен пользователями neovim и создателями [terminal.shop](https://terminal.shop); мы будем раздвигать границы того, что возможно в терминале.
|
||||
- Архитектура клиент/сервер. Например, это позволяет запускать OpenCode на вашем компьютере, а управлять им удаленно из мобильного приложения. Это значит, что TUI-фронтенд - лишь один из возможных клиентов.
|
||||
|
||||
---
|
||||
|
||||
**Присоединяйтесь к нашему сообществу** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode รวมเอเจนต์ในตัวสองตัวที
|
||||
|
||||
หากคุณทำงานในโปรเจกต์ที่เกี่ยวข้องกับ OpenCode และใช้ "opencode" เป็นส่วนหนึ่งของชื่อ เช่น "opencode-dashboard" หรือ "opencode-mobile" โปรดเพิ่มหมายเหตุใน README ของคุณเพื่อชี้แจงว่าไม่ได้สร้างโดยทีม OpenCode และไม่ได้เกี่ยวข้องกับเราในทางใด
|
||||
|
||||
### คำถามที่พบบ่อย
|
||||
|
||||
#### ต่างจาก Claude Code อย่างไร?
|
||||
|
||||
คล้ายกับ Claude Code มากในแง่ความสามารถ นี่คือความแตกต่างหลัก:
|
||||
|
||||
- โอเพนซอร์ส 100%
|
||||
- ไม่ผูกมัดกับผู้ให้บริการใดๆ แม้ว่าเราจะแนะนำโมเดลที่เราจัดหาให้ผ่าน [OpenCode Zen](https://opencode.ai/zen) OpenCode สามารถใช้กับ Claude, OpenAI, Google หรือแม้กระทั่งโมเดลในเครื่องได้ เมื่อโมเดลพัฒนาช่องว่างระหว่างพวกมันจะปิดลงและราคาจะลดลง ดังนั้นการไม่ผูกมัดกับผู้ให้บริการจึงสำคัญ
|
||||
- รองรับ LSP ใช้งานได้ทันทีหลังการติดตั้งโดยไม่ต้องปรับแต่งหรือเปลี่ยนแปลงฟังก์ชันการทำงานใด ๆ
|
||||
- เน้นที่ TUI OpenCode สร้างโดยผู้ใช้ neovim และผู้สร้าง [terminal.shop](https://terminal.shop) เราจะผลักดันขีดจำกัดของสิ่งที่เป็นไปได้ในเทอร์มินัล
|
||||
- สถาปัตยกรรมไคลเอนต์/เซิร์ฟเวอร์ ตัวอย่างเช่น อาจอนุญาตให้ OpenCode ทำงานบนคอมพิวเตอร์ของคุณ ในขณะที่คุณสามารถขับเคลื่อนจากระยะไกลผ่านแอปมือถือ หมายความว่า TUI frontend เป็นหนึ่งในไคลเอนต์ที่เป็นไปได้เท่านั้น
|
||||
|
||||
---
|
||||
|
||||
**ร่วมชุมชนของเรา** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermede
|
||||
|
||||
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
|
||||
|
||||
### SSS
|
||||
|
||||
#### Bu Claude Code'dan nasıl farklı?
|
||||
|
||||
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
|
||||
|
||||
- %100 açık kaynak
|
||||
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
|
||||
- Kurulum gerektirmeyen hazır LSP desteği
|
||||
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
|
||||
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
|
||||
|
||||
---
|
||||
|
||||
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -125,18 +125,6 @@ OpenCode містить два вбудовані агенти, між яким
|
||||
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
|
||||
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Чим це відрізняється від Claude Code?
|
||||
|
||||
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
|
||||
|
||||
- 100% open source
|
||||
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
|
||||
- Підтримка LSP з коробки
|
||||
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
|
||||
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
|
||||
|
||||
---
|
||||
|
||||
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hư
|
||||
|
||||
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
|
||||
|
||||
### Các câu hỏi thường gặp (FAQ)
|
||||
|
||||
#### OpenCode khác biệt thế nào so với Claude Code?
|
||||
|
||||
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
|
||||
|
||||
- 100% mã nguồn mở
|
||||
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
|
||||
- Hỗ trợ LSP ngay từ đầu
|
||||
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
|
||||
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
|
||||
|
||||
---
|
||||
|
||||
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -123,18 +123,6 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。
|
||||
|
||||
### 常见问题 (FAQ)
|
||||
|
||||
#### 这和 Claude Code 有什么不同?
|
||||
|
||||
功能上很相似,关键差异:
|
||||
|
||||
- 100% 开源。
|
||||
- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。
|
||||
- 内置 LSP 支持。
|
||||
- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。
|
||||
- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -123,18 +123,6 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。
|
||||
|
||||
### 常見問題 (FAQ)
|
||||
|
||||
#### 這跟 Claude Code 有什麼不同?
|
||||
|
||||
在功能面上與 Claude Code 非常相似。以下是關鍵差異:
|
||||
|
||||
- 100% 開源。
|
||||
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
|
||||
- 內建 LSP (語言伺服器協定) 支援。
|
||||
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
|
||||
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -305,9 +305,23 @@
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
},
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -337,7 +351,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -353,7 +367,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -366,7 +380,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -384,7 +398,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -421,6 +435,7 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -489,6 +504,7 @@
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
@@ -520,7 +536,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -536,9 +552,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.11",
|
||||
"@opentui/keymap": ">=0.2.11",
|
||||
"@opentui/solid": ">=0.2.11",
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -558,7 +574,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -573,7 +589,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -608,7 +624,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -657,7 +673,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -713,17 +729,18 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/opentelemetry": "4.0.0-beta.65",
|
||||
"@effect/platform-node": "4.0.0-beta.65",
|
||||
"@effect/opentelemetry": "4.0.0-beta.66",
|
||||
"@effect/platform-node": "4.0.0-beta.66",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@lydell/node-pty": "1.2.0-beta.10",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.11",
|
||||
"@opentui/keymap": "0.2.11",
|
||||
"@opentui/solid": "0.2.11",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -735,7 +752,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.12",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "24.12.2",
|
||||
@@ -745,9 +762,9 @@
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.66",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -764,7 +781,7 @@
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.2",
|
||||
"ulid": "3.0.1",
|
||||
"virtua": "0.42.3",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"vite-plugin-solid": "2.11.10",
|
||||
"zod": "4.1.8",
|
||||
@@ -1106,11 +1123,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.66", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.66" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-LU3ejAzJS+4P+Qtfn9ULnsGcIPmx1tUUB2ZswFRL+EolD8US7zMljHTwGuQRUBJOjDwt7wFCMN5AR512vdY8FQ=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.66", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.66", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.66", "ioredis": "^5.7.0" } }, "sha512-s/0RgaQFuszzdorRnX1PwEQNnSOi+JgMJo3zEe9O2NR3sosMhTr0Uk+1AF6bUOI9uJ2CPT3KpTIIU7q5/TpOkg=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.66", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.66", "", { "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-UYsrAb/5T0ZRypeN9Kmv3/ZInibGCjM6dtoiAWtfG+xKyuq8N05wmuVCXB0+XgVmUBxDWjw/S1fu4ivS0vZVuw=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -1540,6 +1559,8 @@
|
||||
|
||||
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
|
||||
|
||||
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
|
||||
|
||||
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
|
||||
|
||||
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||
@@ -1590,23 +1611,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -2282,7 +2303,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
|
||||
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
|
||||
|
||||
@@ -2694,7 +2715,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -2972,9 +2993,9 @@
|
||||
|
||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
|
||||
|
||||
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||
|
||||
@@ -2988,7 +3009,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="],
|
||||
"effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -4904,7 +4925,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"virtua": ["virtua@0.42.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-5FoAKcEvh05qsUF97Yz42SWJ7bwnPExjUYHGuoxz1EUtfWtaOgXaRwnylJbDpA0QcH1rKvJ2qsGRi9MK1fpQbg=="],
|
||||
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
|
||||
|
||||
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
|
||||
|
||||
@@ -5676,6 +5697,8 @@
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
|
||||
|
||||
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
|
||||
|
||||
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
@@ -5834,10 +5857,6 @@
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core": ["@opentui/core@0.2.7", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.7", "@opentui/core-darwin-x64": "0.2.7", "@opentui/core-linux-arm64": "0.2.7", "@opentui/core-linux-x64": "0.2.7", "@opentui/core-win32-arm64": "0.2.7", "@opentui/core-win32-x64": "0.2.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-cnN6JcaGC7SeQzobBy/CHzqUAQFtypazuw1CjQBo7WwoOiLMGubt9W5FXeF0zIrSxH2Ed6NLWhPYRg7SD4629Q=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-nlkx9HvuWaHtc5A8eUEAPNi+5+37LZS3ln73WRmtT5xin8LnQf+yhwopqGgPSnLq1ODLwhkKRdr/9JCDr2j7Bg=="],
|
||||
|
||||
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
@@ -6608,22 +6627,6 @@
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CAy6cL3byz2Xf6gFiJHBpcnsp/2ADEWLLOUokVypOyPLcy8GY3sPzlA4pkAjVGQMYQhDj+Y3+SXz4uTLt4AETg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-K06h333rMkC9cyMJr/VvcRK3ik81Admd8ZsES5uf5YXWPdYhXGf75I1T8mKIThhUmoFLb8R5xqfuPmoocsjM7Q=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-iYWGTztbdG9yYSB5Alxuo0dWAmkWQR0+/paNWUyPOocjigmKgMmACDtHgYqa7sxkIcWgmXljt/f8rgXDG4wdMg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tymBCfYbsDRfHQNXsolkFfaTEIDhemD4+1ZovUztQd7i+0Ggnu9WbPN1SNCiRz6PjrlaNeQzZE3Wl8FfVdw/cw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-XLPJWdT8QOukrYDkpIng6+uNUlF66ByXcQlC3qA9JbrUTBetZhgXs8Q2jEjRfc+Ty3uh1iRSA6PgJGbbOK/f4Q=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-CzVGEfqysVk8Hxcj0RDv/DtXIM6iZmbmr23kW7y8CJMPtmV1gmKI4D9abVjynWJnGbaSBnDi43mgZnGMgOdyEg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
@@ -6974,8 +6977,6 @@
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
@@ -37,16 +37,14 @@
|
||||
node_modules = final.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
};
|
||||
in
|
||||
rec {
|
||||
opencode = final.callPackage ./nix/opencode.nix {
|
||||
inherit node_modules;
|
||||
};
|
||||
desktop = final.callPackage ./nix/desktop.nix {
|
||||
opencode-desktop = final.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit opencode;
|
||||
opencode-desktop = desktop;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,16 +54,15 @@
|
||||
node_modules = pkgs.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
};
|
||||
in
|
||||
rec {
|
||||
default = opencode;
|
||||
opencode = pkgs.callPackage ./nix/opencode.nix {
|
||||
inherit node_modules;
|
||||
};
|
||||
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
opencode-desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
in
|
||||
{
|
||||
default = opencode;
|
||||
inherit opencode desktop;
|
||||
# Updater derivation with fakeHash - build fails and reveals correct hash
|
||||
node_modules_updater = node_modules.override {
|
||||
hash = pkgs.lib.fakeHash;
|
||||
|
||||
@@ -223,8 +223,6 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
|
||||
properties: { value: stripeWebhook.secret },
|
||||
})
|
||||
|
||||
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
||||
|
||||
////////////////
|
||||
// CONSOLE
|
||||
////////////////
|
||||
@@ -274,7 +272,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
|
||||
]
|
||||
: []),
|
||||
gatewayKv,
|
||||
],
|
||||
environment: {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
|
||||
+27
-6
@@ -2,6 +2,7 @@ import { SECRET } from "./secret"
|
||||
import { domain } from "./stage"
|
||||
|
||||
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
||||
const alertsDisabled = $app.stage !== "production"
|
||||
|
||||
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
|
||||
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
|
||||
@@ -46,10 +47,24 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
]
|
||||
const failedHttpStatus = calculatedField({
|
||||
name: "is_failed_http_status",
|
||||
expression:
|
||||
product === "go"
|
||||
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
|
||||
: `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
|
||||
expression: `
|
||||
IF(
|
||||
AND(
|
||||
GTE($status, "400"),
|
||||
NOT(EQUALS($status, "401")),
|
||||
NOT(
|
||||
AND(
|
||||
EQUALS($status, "429"),
|
||||
OR(
|
||||
EQUALS($error.type, "GoUsageLimitError"),
|
||||
EQUALS($error.type, "FreeUsageLimitError")
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
1,
|
||||
0
|
||||
)`,
|
||||
})
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
@@ -65,7 +80,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
filters,
|
||||
},
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 150), DIV($FAILED, $TOTAL), 0)" }],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
}
|
||||
@@ -108,7 +123,7 @@ const providerHttpErrorsQuery = () => {
|
||||
},
|
||||
],
|
||||
formulas: [
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 150), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
@@ -145,6 +160,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
name: "Increased Model HTTP Errors [Go]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
queryJson: modelHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
@@ -164,6 +180,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
name: "Increased Model HTTP Errors [Zen]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
queryJson: modelHttpErrorsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
@@ -183,6 +200,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
new honeycomb.Trigger("LowModelTpsGo", {
|
||||
name: "Low Model TPS [Go]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
queryJson: modelLowTpsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 600,
|
||||
@@ -202,6 +220,7 @@ new honeycomb.Trigger("LowModelTpsGo", {
|
||||
new honeycomb.Trigger("LowModelTpsZen", {
|
||||
name: "Low Model TPS [Zen]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
queryJson: modelLowTpsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 600,
|
||||
@@ -221,6 +240,7 @@ new honeycomb.Trigger("LowModelTpsZen", {
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
||||
name: "Increased Provider HTTP Errors",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
queryJson: providerHttpErrorsQuery(),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
@@ -240,6 +260,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
||||
new honeycomb.Trigger("IncreasedFreeTierRequests", {
|
||||
name: "Increased Free Tier Requests",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
queryJson: honeycomb.getQuerySpecificationOutput({
|
||||
calculations: [{ op: "COUNT" }],
|
||||
filters: [
|
||||
|
||||
+74
-73
@@ -1,100 +1,101 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
rustPlatform,
|
||||
pkg-config,
|
||||
cargo-tauri,
|
||||
bun,
|
||||
nodejs,
|
||||
cargo,
|
||||
rustc,
|
||||
jq,
|
||||
wrapGAppsHook4,
|
||||
electron_41,
|
||||
makeWrapper,
|
||||
dbus,
|
||||
glib,
|
||||
gtk4,
|
||||
libsoup_3,
|
||||
librsvg,
|
||||
libappindicator,
|
||||
glib-networking,
|
||||
openssl,
|
||||
webkitgtk_4_1,
|
||||
gst_all_1,
|
||||
writableTmpDirAsHomeHook,
|
||||
autoPatchelfHook,
|
||||
opencode,
|
||||
}:
|
||||
rustPlatform.buildRustPackage (finalAttrs: {
|
||||
let
|
||||
electron = electron_41;
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
inherit (opencode)
|
||||
version
|
||||
src
|
||||
node_modules
|
||||
patches
|
||||
;
|
||||
|
||||
cargoRoot = "packages/desktop/src-tauri";
|
||||
cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock;
|
||||
buildAndTestSubdir = finalAttrs.cargoRoot;
|
||||
inherit (opencode) version src node_modules;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
cargo-tauri.hook
|
||||
bun
|
||||
nodejs # for patchShebangs node_modules
|
||||
cargo
|
||||
rustc
|
||||
jq
|
||||
nodejs
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||
|
||||
buildInputs = lib.optionals stdenv.isLinux [
|
||||
dbus
|
||||
glib
|
||||
gtk4
|
||||
libsoup_3
|
||||
librsvg
|
||||
libappindicator
|
||||
glib-networking
|
||||
openssl
|
||||
webkitgtk_4_1
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
writableTmpDirAsHomeHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
];
|
||||
|
||||
strictDeps = true;
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
(lib.getLib stdenv.cc.cc)
|
||||
];
|
||||
|
||||
env = opencode.env // {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
};
|
||||
|
||||
# https://github.com/electron/electron/issues/31121
|
||||
# mac builds use a .app bundle which doesnt have this issue
|
||||
postPatch = lib.optionalString stdenv.isLinux ''
|
||||
BASE_PATH=packages/desktop
|
||||
FILES=(src/main/windows.ts)
|
||||
for file in "''${FILES[@]}"; do
|
||||
substituteInPlace $BASE_PATH/$file \
|
||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||
done
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
cp -a ${finalAttrs.node_modules}/{node_modules,packages} .
|
||||
chmod -R u+w node_modules packages
|
||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||
chmod -R u+w $HOME/.electron-dist
|
||||
|
||||
cp -R ${finalAttrs.node_modules}/. .
|
||||
patchShebangs node_modules
|
||||
patchShebangs packages/desktop/node_modules
|
||||
|
||||
mkdir -p packages/desktop/src-tauri/sidecars
|
||||
cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget}
|
||||
patchShebangs packages/*/node_modules
|
||||
'';
|
||||
|
||||
# see publish-tauri job in .github/workflows/publish.yml
|
||||
tauriBuildFlags = [
|
||||
"--config"
|
||||
"tauri.prod.conf.json"
|
||||
"--no-sign" # no code signing or auto updates
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
cd packages/desktop
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
--config electron-builder.config.ts \
|
||||
--config.mac.identity=null \
|
||||
--config.electronDist="$HOME/.electron-dist"
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase =
|
||||
''
|
||||
runHook preInstall
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
mkdir -p $out/Applications
|
||||
mv dist/mac*/*.app $out/Applications
|
||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mkdir -p $out/opt/opencode-desktop
|
||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||
--inherit-argv0 \
|
||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||
''
|
||||
+ ''
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
autoPatchelfIgnoreMissingDeps = [
|
||||
"libc.musl-x86_64.so.1"
|
||||
];
|
||||
|
||||
# FIXME: workaround for concerns about case insensitive filesystems
|
||||
# should be removed once binary is renamed or decided otherwise
|
||||
# darwin output is a .app bundle so no conflict
|
||||
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mv $out/bin/OpenCode $out/bin/opencode-desktop
|
||||
sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "OpenCode Desktop App";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode-desktop";
|
||||
inherit (opencode.meta) platforms;
|
||||
inherit (opencode.meta) homepage license platforms;
|
||||
};
|
||||
})
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
|
||||
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
|
||||
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
|
||||
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
|
||||
"x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
|
||||
"aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
|
||||
"aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
|
||||
"x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -40,7 +40,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
||||
env.OPENCODE_DISABLE_MODELS_FETCH = true;
|
||||
env.OPENCODE_VERSION = finalAttrs.version;
|
||||
env.OPENCODE_CHANNEL = "local";
|
||||
env.OPENCODE_CHANNEL = "prod";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
@@ -89,11 +89,12 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
|
||||
passthru = {
|
||||
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
|
||||
env = finalAttrs.env;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "The open source coding agent";
|
||||
homepage = "https://opencode.ai/";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode";
|
||||
inherit (node_modules.meta) platforms;
|
||||
|
||||
+12
-11
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.13",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
@@ -28,16 +28,17 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.65",
|
||||
"@effect/platform-node": "4.0.0-beta.65",
|
||||
"@effect/opentelemetry": "4.0.0-beta.66",
|
||||
"@effect/platform-node": "4.0.0-beta.66",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.12",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.11",
|
||||
"@opentui/keymap": "0.2.11",
|
||||
"@opentui/solid": "0.2.11",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -53,9 +54,9 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.66",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -74,7 +75,7 @@
|
||||
"shiki": "3.20.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"virtua": "0.42.3",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
const words = [
|
||||
"alpha",
|
||||
"bravo",
|
||||
"charlie",
|
||||
"delta",
|
||||
"echo",
|
||||
"foxtrot",
|
||||
"golf",
|
||||
"hotel",
|
||||
"india",
|
||||
"juliet",
|
||||
"kilo",
|
||||
"lima",
|
||||
"metro",
|
||||
"nova",
|
||||
"orbit",
|
||||
"pixel",
|
||||
"quartz",
|
||||
"river",
|
||||
"signal",
|
||||
"vector",
|
||||
]
|
||||
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
let i = seed
|
||||
while (out.length < length) {
|
||||
const word = words[i % words.length]
|
||||
out += (out ? " " : "") + word
|
||||
if (i % 17 === 0) out += ".\n\n"
|
||||
i += 7
|
||||
}
|
||||
return out.slice(0, length)
|
||||
}
|
||||
|
||||
function id(prefix: string, value: number) {
|
||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: id("prt_user_text", index),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: lorem(index, textLength),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
parentID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
||||
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
|
||||
}
|
||||
|
||||
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
||||
return {
|
||||
id: id(`prt_reasoning_${partIndex}`, index),
|
||||
type: "reasoning",
|
||||
text: lorem(index * 19 + partIndex, length),
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
||||
}
|
||||
}
|
||||
|
||||
function toolPart(
|
||||
index: number,
|
||||
partIndex: number,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
outputLength = 160,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
||||
diff: patch(index, outputLength),
|
||||
preview: patch(index + 1, 420),
|
||||
}
|
||||
: tool === "question"
|
||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||
: {}
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
callID: id("call", index * 10 + partIndex),
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath: `src/generated/patch-${seed}.ts`,
|
||||
relativePath: `src/generated/patch-${seed}.ts`,
|
||||
type,
|
||||
additions: (seed % 7) + 1,
|
||||
deletions: type === "add" ? 0 : seed % 4,
|
||||
patch: patch(seed, 520),
|
||||
before: type === "add" ? undefined : code(seed, 18),
|
||||
after: type === "delete" ? undefined : code(seed + 1, 24),
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string, seed: number) {
|
||||
return {
|
||||
file,
|
||||
additions: (seed % 9) + 1,
|
||||
deletions: seed % 4,
|
||||
before: code(seed, 32),
|
||||
after: code(seed + 1, 38),
|
||||
}
|
||||
}
|
||||
|
||||
function patch(seed: number, length: number) {
|
||||
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
||||
}
|
||||
|
||||
function code(seed: number, lines: number) {
|
||||
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number): Message[] {
|
||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||
const parts = [
|
||||
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
||||
...(index % 3 === 0
|
||||
? [
|
||||
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
||||
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
||||
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
||||
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
||||
]
|
||||
: []),
|
||||
textPart(index, 2, 160 + (index % 6) * 90),
|
||||
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
...(index % 13 === 0
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
11,
|
||||
"question",
|
||||
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
||||
120,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(index % 17 === 0
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function orderedParts(message: Message) {
|
||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "smoke-project",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sourceID,
|
||||
slug: "source",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Uncommitted changes inquiry",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
{
|
||||
id: targetID,
|
||||
slug: "target",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Example Game: sample jump movement & sample physics analysis",
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
],
|
||||
sourceID,
|
||||
targetID,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
messages.findIndex((message) => message.info.id === before),
|
||||
)
|
||||
: messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const forbiddenText = ["Load details", "Show earlier steps"]
|
||||
|
||||
type SmokeState = {
|
||||
ids: string[]
|
||||
visibleIds: string[]
|
||||
messageIds: string[]
|
||||
visibleMessageIds: string[]
|
||||
topVisibleId?: string
|
||||
signature: string
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
errorToasts: string[]
|
||||
forbiddenText: string[]
|
||||
}
|
||||
|
||||
type SmokeWindow = Window & {
|
||||
__timelineSmokeState?: () => SmokeState
|
||||
__timelineSmokeErrorToasts?: string[]
|
||||
__timelineSmokeForbiddenText?: string[]
|
||||
}
|
||||
|
||||
test.describe("smoke: session timeline", () => {
|
||||
test.setTimeout(240_000)
|
||||
|
||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await configureSmokePage(page)
|
||||
|
||||
await openProject(page, "SmokeProject")
|
||||
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await expectSessionReady(page, "smoke-project")
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
})
|
||||
})
|
||||
|
||||
async function configureSmokePage(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const smoke = window as SmokeWindow
|
||||
smoke.__timelineSmokeErrorToasts = []
|
||||
smoke.__timelineSmokeForbiddenText = []
|
||||
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
|
||||
const idsOf = (el: HTMLElement) =>
|
||||
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
|
||||
|
||||
smoke.__timelineSmokeState = () => {
|
||||
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
||||
el.querySelector("[data-timeline-row], [data-session-title]"),
|
||||
)
|
||||
if (!scroller) {
|
||||
return {
|
||||
ids: [],
|
||||
visibleIds: [],
|
||||
messageIds: [],
|
||||
visibleMessageIds: [],
|
||||
topVisibleId: undefined,
|
||||
signature: "",
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
||||
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const ids: string[] = []
|
||||
const visibleIds: string[] = []
|
||||
const scrollerRect = scroller.getBoundingClientRect()
|
||||
let topVisibleId: string | undefined
|
||||
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
|
||||
const next = idsOf(el)
|
||||
ids.push(...next)
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
|
||||
if (!topVisibleId) topVisibleId = next[0]
|
||||
visibleIds.push(...next)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIds: string[] = []
|
||||
const visibleMessageIds: string[] = []
|
||||
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const id = el.dataset.messageId
|
||||
if (id) {
|
||||
messageIds.push(id)
|
||||
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
|
||||
}
|
||||
return {
|
||||
id,
|
||||
top: Math.round(rect.top),
|
||||
bottom: Math.round(rect.bottom),
|
||||
}
|
||||
})
|
||||
const signature = JSON.stringify({
|
||||
top: Math.round(scroller.scrollTop),
|
||||
height: Math.round(scroller.scrollHeight),
|
||||
rows,
|
||||
ids,
|
||||
})
|
||||
|
||||
return {
|
||||
ids,
|
||||
visibleIds,
|
||||
messageIds,
|
||||
visibleMessageIds,
|
||||
topVisibleId,
|
||||
signature,
|
||||
scrollTop: Math.round(scroller.scrollTop),
|
||||
scrollHeight: Math.round(scroller.scrollHeight),
|
||||
clientHeight: Math.round(scroller.clientHeight),
|
||||
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
||||
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
||||
}
|
||||
}
|
||||
let recordFrame: number | undefined
|
||||
const record = () => {
|
||||
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
|
||||
const text = toast.textContent?.trim()
|
||||
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
|
||||
}
|
||||
const text = document.body?.textContent ?? ""
|
||||
for (const value of ["Load details", "Show earlier steps"]) {
|
||||
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
|
||||
smoke.__timelineSmokeForbiddenText!.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const start = () => {
|
||||
const root = document.documentElement ?? document.body
|
||||
if (!root) return
|
||||
new MutationObserver(() => {
|
||||
if (recordFrame) return
|
||||
recordFrame = requestAnimationFrame(() => {
|
||||
recordFrame = undefined
|
||||
record()
|
||||
})
|
||||
}).observe(root, { childList: true, subtree: true })
|
||||
record()
|
||||
}
|
||||
if (document.documentElement ?? document.body) start()
|
||||
else document.addEventListener("DOMContentLoaded", start, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCanScrollToStart(
|
||||
page: Page,
|
||||
expectedPartIDs: string[],
|
||||
expectedMessageIDs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
await pointAtTimeline(page)
|
||||
const seenParts = new Set<string>()
|
||||
const seenMessages = new Set<string>()
|
||||
const samples: TraversalSample[] = []
|
||||
let current = await timelineState(page)
|
||||
let unchangedAtTop = 0
|
||||
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
collectSeen(current, seenParts, seenMessages)
|
||||
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
||||
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
|
||||
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
|
||||
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
|
||||
|
||||
if (
|
||||
current.scrollTop <= 1 &&
|
||||
seenParts.size === expectedPartIDs.length &&
|
||||
seenMessages.size === expectedMessageIDs.length
|
||||
) {
|
||||
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
||||
return
|
||||
}
|
||||
|
||||
const before = current
|
||||
const changed = await scrollTimelineUp(page, current)
|
||||
current = await timelineState(page)
|
||||
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
|
||||
else unchangedAtTop = 0
|
||||
if (unchangedAtTop >= 2) break
|
||||
}
|
||||
|
||||
collectSeen(current, seenParts, seenMessages)
|
||||
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
||||
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
||||
}
|
||||
|
||||
async function timelineState(page: Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as SmokeWindow).__timelineSmokeState?.() ?? {
|
||||
ids: [],
|
||||
visibleIds: [],
|
||||
messageIds: [],
|
||||
visibleMessageIds: [],
|
||||
topVisibleId: undefined,
|
||||
signature: "",
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
errorToasts: [],
|
||||
forbiddenText: [],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function timelineScroller(page: Page) {
|
||||
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
}
|
||||
|
||||
async function pointAtTimeline(page: Page) {
|
||||
const box = await timelineScroller(page).boundingBox()
|
||||
if (!box) throw new Error("Timeline scroller is not visible")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
}
|
||||
|
||||
async function scrollTimelineUp(page: Page, before: SmokeState) {
|
||||
return page.evaluate(
|
||||
(prev) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
||||
el.querySelector("[data-timeline-row], [data-session-title]"),
|
||||
)
|
||||
if (!scroller) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
|
||||
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
|
||||
|
||||
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||
let frames = 0
|
||||
let stableFrames = 0
|
||||
let last = ""
|
||||
let changed = false
|
||||
const check = () => {
|
||||
const current = read()
|
||||
if (current !== prev) changed = true
|
||||
if (current === last) stableFrames++
|
||||
else {
|
||||
stableFrames = 0
|
||||
last = current
|
||||
}
|
||||
if (changed && stableFrames >= 2) {
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
frames++
|
||||
if (frames >= 30) {
|
||||
resolve(changed)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(check)
|
||||
}
|
||||
requestAnimationFrame(check)
|
||||
}),
|
||||
before.signature,
|
||||
)
|
||||
}
|
||||
|
||||
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
|
||||
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
|
||||
const actualSet = new Set(actual)
|
||||
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
|
||||
}
|
||||
|
||||
function unique(values: string[]) {
|
||||
return values.filter((value, index) => values.indexOf(value) === index)
|
||||
}
|
||||
|
||||
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
|
||||
for (const id of state.ids) seenParts.add(id)
|
||||
for (const id of state.visibleIds) seenParts.add(id)
|
||||
for (const id of state.messageIds) seenMessages.add(id)
|
||||
for (const id of state.visibleMessageIds) seenMessages.add(id)
|
||||
}
|
||||
|
||||
type TraversalSample = ReturnType<typeof sampleTraversal>
|
||||
|
||||
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
|
||||
return {
|
||||
seenParts,
|
||||
seenMessages,
|
||||
mounted: state.ids.length,
|
||||
visible: state.visibleIds.length,
|
||||
mountedMessages: unique(state.messageIds).length,
|
||||
visibleMessages: unique(state.visibleMessageIds).length,
|
||||
top: state.scrollTop,
|
||||
height: state.scrollHeight,
|
||||
first: state.ids[0],
|
||||
last: state.ids.at(-1),
|
||||
topVisible: state.topVisibleId,
|
||||
visibleFirst: state.visibleIds[0],
|
||||
visibleLast: state.visibleIds.at(-1),
|
||||
}
|
||||
}
|
||||
|
||||
function sampleSummary(samples: TraversalSample[]) {
|
||||
return samples
|
||||
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
|
||||
.map(
|
||||
(sample, index) =>
|
||||
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
|
||||
)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
async function waitForTimelineStable(page: Page) {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||
requestAnimationFrame(() => {
|
||||
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||
requestAnimationFrame(() =>
|
||||
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function expectSessionTimelineReady(
|
||||
page: Page,
|
||||
expectedPartIDs: string[],
|
||||
expectedMessageIDs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
await waitForTimelineStable(page)
|
||||
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
||||
const currentState = await timelineState(page)
|
||||
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
|
||||
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
|
||||
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
|
||||
}
|
||||
|
||||
function expectCompleteScroll(
|
||||
state: SmokeState,
|
||||
expectedPartIDs: string[],
|
||||
expectedMessageIDs: string[],
|
||||
seenParts: Set<string>,
|
||||
seenMessages: Set<string>,
|
||||
samples: TraversalSample[],
|
||||
) {
|
||||
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
|
||||
expect(
|
||||
expectedPartIDs.filter((id) => !seenParts.has(id)),
|
||||
`missing visible timeline parts\n${sampleSummary(samples)}`,
|
||||
).toEqual([])
|
||||
expect(
|
||||
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
|
||||
`missing visible messages\n${sampleSummary(samples)}`,
|
||||
).toEqual([])
|
||||
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
||||
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
||||
expect(expectedPartIDs.length).toBe(331)
|
||||
}
|
||||
|
||||
async function openProject(page: Page, projectName: string) {
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
|
||||
}
|
||||
|
||||
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
||||
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
|
||||
await page
|
||||
.locator(`a[href*="${sessionId}"]`)
|
||||
.first()
|
||||
.evaluate((el) => (el as HTMLElement).click())
|
||||
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectSessionReady(page: Page, projectName: string) {
|
||||
await expect(page.getByText(projectName).first()).toBeVisible()
|
||||
await expect(page.getByText("Ask anything...")).toBeVisible()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { test } from "@playwright/test"
|
||||
|
||||
test(
|
||||
"test something cool",
|
||||
{
|
||||
annotation: { type: "todo" },
|
||||
},
|
||||
async () => {
|
||||
test.fixme()
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
|
||||
export function trackPageErrors(page: Page) {
|
||||
const errors: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text())
|
||||
})
|
||||
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
|
||||
return errors
|
||||
}
|
||||
|
||||
export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
|
||||
expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
|
||||
consoleErrors: [],
|
||||
toastErrors: [],
|
||||
forbiddenText: [],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set([
|
||||
"/skill",
|
||||
"/command",
|
||||
"/lsp",
|
||||
"/formatter",
|
||||
"/permission",
|
||||
"/question",
|
||||
"/vcs/status",
|
||||
"/vcs/diff",
|
||||
])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
directory: string
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/provider": config.provider,
|
||||
"/path": {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
},
|
||||
"/project": [config.project],
|
||||
"/project/current": config.project,
|
||||
"/agent": [{ name: "build", mode: "primary" }],
|
||||
"/vcs": { branch: "main", default_branch: "main" },
|
||||
"/session": config.sessions,
|
||||
}
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
if (url.port !== targetPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route)
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const before = url.searchParams.get("before") ?? undefined
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||
}
|
||||
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "x-next-cursor",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body ?? null),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
// Update theme-color meta tag to match app color scheme
|
||||
var metas = document.querySelectorAll("meta[name='theme-color']")
|
||||
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
|
||||
|
||||
if (themeId === "oc-2") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { JSX } from "solid-js"
|
||||
|
||||
export type DialogGoUpsellProps = {
|
||||
title: string
|
||||
description: JSX.Element
|
||||
link?: string
|
||||
actionLabel: string
|
||||
onClose?: (dontShowAgain?: boolean) => void
|
||||
}
|
||||
|
||||
export function DialogUsageExceeded(props: DialogGoUpsellProps) {
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
|
||||
const runAction = () => {
|
||||
if (props.link) platform.openLink(props.link)
|
||||
props.onClose?.()
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
props.onClose?.(true)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={props.title} description={props.description} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={dismiss}>
|
||||
Don't show again
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={runAction}>
|
||||
{props.actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -99,8 +99,6 @@ const EXAMPLES = [
|
||||
"prompt.example.25",
|
||||
] as const
|
||||
|
||||
const NON_EMPTY_TEXT = /[^\s\u200B]/
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const queryOptions = useQueryOptions()
|
||||
@@ -860,7 +858,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
? rawParts[0].content
|
||||
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||
const hasNonText = rawParts.some((part) => part.type !== "text")
|
||||
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
|
||||
const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "")
|
||||
const shouldReset =
|
||||
textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0
|
||||
|
||||
if (shouldReset) {
|
||||
closePopover()
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { decodeDirectory } from "@/pages/directory-layout"
|
||||
import { iife } from "@opencode-ai/core/util/iife"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -39,6 +44,8 @@ const titlebarHeight = 40
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
export function Titlebar() {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
@@ -52,6 +59,7 @@ export function Titlebar() {
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const web = createMemo(() => platform.platform === "web")
|
||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
@@ -175,162 +183,378 @@ export function Titlebar() {
|
||||
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative overflow-hidden"
|
||||
style={{ "min-height": minHeight() }}
|
||||
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
|
||||
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<div
|
||||
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
||||
style={{ zoom: counterZoom() }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center min-w-0": true,
|
||||
"pl-2": !mac(),
|
||||
}}
|
||||
>
|
||||
<Show when={mac()}>
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md"
|
||||
onClick={layout.mobileSidebar.toggle}
|
||||
aria-label={language.t("sidebar.menu.toggle")}
|
||||
aria-expanded={layout.mobileSidebar.opened()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!mac()}>
|
||||
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md"
|
||||
onClick={layout.mobileSidebar.toggle}
|
||||
aria-label={language.t("sidebar.menu.toggle")}
|
||||
aria-expanded={layout.mobileSidebar.opened()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
||||
placement="bottom"
|
||||
title={language.t("command.sidebar.toggle")}
|
||||
keybind={command.keybind("sidebar.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={layout.sidebar.toggle}
|
||||
aria-label={language.t("command.sidebar.toggle")}
|
||||
aria-expanded={layout.sidebar.opened()}
|
||||
>
|
||||
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
<Show when={params.dir}>
|
||||
<div
|
||||
class="flex items-center shrink-0 w-8 mr-1"
|
||||
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||
<Switch>
|
||||
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
{(_) => {
|
||||
const globalSync = useGlobalSync()
|
||||
const navigate = useNavigate()
|
||||
|
||||
type Tab = { dir: string; sessionId: string; params: any; href: string }
|
||||
|
||||
const [tabsStore, tabsStoreActions] = iife(() => {
|
||||
const [store, setStore] = createStore<Tab[]>(
|
||||
iife(() => {
|
||||
if (!params.dir || !params.id) return []
|
||||
return [
|
||||
{
|
||||
dir: decodeDirectory(params.dir) ?? "",
|
||||
sessionId: params.id,
|
||||
params: { id: params.id, dir: params.dir },
|
||||
href: makeSessionHref(params.dir, params.id),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
const actions = {
|
||||
addTab: (tab: Tab) => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((t) => t.href === tab.href)) return
|
||||
|
||||
tabs.push(tab)
|
||||
}),
|
||||
)
|
||||
},
|
||||
removeTab: (href: string) => {
|
||||
startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((t) => t.href === href)
|
||||
if (index === -1) return
|
||||
tabs.splice(index, 1)
|
||||
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
|
||||
if (nextTab) navigate(nextTab.href)
|
||||
else navigate("/")
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return [store, actions]
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const params = useParams()
|
||||
if (!(params.dir && params.id)) return
|
||||
|
||||
tabsStoreActions.addTab({
|
||||
dir: decodeDirectory(params.dir) ?? "",
|
||||
sessionId: params.id,
|
||||
params: { id: params.id, dir: params.dir },
|
||||
href: makeSessionHref(params.dir, params.id),
|
||||
})
|
||||
})
|
||||
|
||||
const tabsEnriched = iife(() => {
|
||||
const base = mapArray(
|
||||
() => tabsStore,
|
||||
(tab) => {
|
||||
const sync = globalSync.createDirSyncContext(tab.dir)
|
||||
const session = sync.session.get(tab.sessionId)
|
||||
return session ? { ...tab, info: session } : null
|
||||
},
|
||||
)
|
||||
|
||||
return () => base().flatMap((s) => (s ? [s] : []))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
|
||||
<ChannelIndicator />
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
as="a"
|
||||
href="/"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-8"
|
||||
state={!!useMatch(() => "/")() ? "pressed" : undefined}
|
||||
>
|
||||
<div
|
||||
class="transition-opacity"
|
||||
classList={{
|
||||
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</IconButtonV2>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<For each={tabsEnriched()}>
|
||||
{(tab, i) => (
|
||||
<>
|
||||
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
|
||||
<TabNavItem
|
||||
href={tab.href}
|
||||
title={tab.info.title}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<button>
|
||||
<div class="p-1.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
<path
|
||||
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
|
||||
stroke="#808080"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="flex-1" />
|
||||
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
|
||||
stroke="#3A3A3A"
|
||||
/>
|
||||
</svg>
|
||||
</button>*/}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when>
|
||||
<div
|
||||
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
||||
style={{ zoom: counterZoom() }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center min-w-0": true,
|
||||
"pl-2": !mac(),
|
||||
}}
|
||||
>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
</Show>
|
||||
<Show when={mac()}>
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md"
|
||||
onClick={layout.mobileSidebar.toggle}
|
||||
aria-label={language.t("sidebar.menu.toggle")}
|
||||
aria-expanded={layout.mobileSidebar.opened()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="flex items-center shrink-0"
|
||||
classList={{
|
||||
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<div class="flex items-center gap-0 transition-transform">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show when={!mac()}>
|
||||
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md"
|
||||
onClick={layout.mobileSidebar.toggle}
|
||||
aria-label={language.t("sidebar.menu.toggle")}
|
||||
aria-expanded={layout.mobileSidebar.opened()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
||||
placement="bottom"
|
||||
title={language.t("command.sidebar.toggle")}
|
||||
keybind={command.keybind("sidebar.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={layout.sidebar.toggle}
|
||||
aria-label={language.t("command.sidebar.toggle")}
|
||||
aria-expanded={layout.sidebar.opened()}
|
||||
>
|
||||
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
<Show when={params.dir}>
|
||||
<div
|
||||
class="flex items-center shrink-0 w-8 mr-1"
|
||||
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||
>
|
||||
<div
|
||||
class="transition-opacity"
|
||||
classList={{
|
||||
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="flex items-center shrink-0"
|
||||
classList={{
|
||||
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<div class="flex items-center gap-0 transition-transform">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<ChannelIndicator />
|
||||
</div>
|
||||
</Show>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
id="opencode-titlebar-center"
|
||||
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center min-w-0 justify-end": true,
|
||||
"pr-2": !windows(),
|
||||
}}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center min-w-0 justify-end": true,
|
||||
"pr-2": !windows(),
|
||||
}}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
return (
|
||||
<div
|
||||
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
|
||||
data-active={isActive()}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
|
||||
>
|
||||
{props.title}
|
||||
</a>
|
||||
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div
|
||||
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
|
||||
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
|
||||
}}
|
||||
/>
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||
onClick={props.onClose}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function ChannelIndicator() {
|
||||
return (
|
||||
<>
|
||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export function WindowsAppMenu(props: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
}) {
|
||||
let lastFocused: HTMLElement | undefined
|
||||
|
||||
const rememberFocus = () => {
|
||||
const active = document.activeElement
|
||||
lastFocused = active instanceof HTMLElement ? active : undefined
|
||||
}
|
||||
const commandDisabled = (id: string) => {
|
||||
const option = props.command.options.find((option) => option.id === id)
|
||||
if (!option) return true
|
||||
return option.disabled ?? false
|
||||
}
|
||||
const runCommand = (id: string) => {
|
||||
if (commandDisabled(id)) return
|
||||
props.command.trigger(id)
|
||||
}
|
||||
const runAction = (action: DesktopMenuAction) => {
|
||||
if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true })
|
||||
void props.platform.runDesktopMenuAction?.(action)
|
||||
}
|
||||
const runEntry = (entry: DesktopMenuEntry) => {
|
||||
if (entry.type === "separator") return
|
||||
if (entry.command) {
|
||||
runCommand(entry.command)
|
||||
return
|
||||
}
|
||||
if (entry.action) {
|
||||
runAction(entry.action)
|
||||
return
|
||||
}
|
||||
if (entry.href) props.platform.openLink(entry.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label="OpenCode menu"
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="desktop-app-menu">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||
<DesktopMenuSubmenu label={menu.label}>
|
||||
{menu.items
|
||||
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||
.map((entry) =>
|
||||
entry.type === "separator" ? (
|
||||
<DropdownMenu.Separator />
|
||||
) : (
|
||||
<DesktopMenuItem
|
||||
label={entry.label ?? ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</DesktopMenuSubmenu>
|
||||
))}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
|
||||
return (
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>
|
||||
<span data-slot="dropdown-menu-item-label">{props.label}</span>
|
||||
<span data-slot="desktop-app-menu-chevron">
|
||||
<Icon name="chevron-right" size="small" />
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Sub>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
|
||||
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
|
||||
<Show when={props.keybind}>
|
||||
<span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
|
||||
</Show>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
getSessionPrefetchPromise,
|
||||
setSessionPrefetch,
|
||||
} from "./global-sync/session-prefetch"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||
const pending = map.get(key)
|
||||
if (pending) return pending
|
||||
const promise = task().finally(() => {
|
||||
map.delete(key)
|
||||
})
|
||||
map.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
const map = new Map(a.map((item) => [item.id, item] as const))
|
||||
for (const item of b) map.set(item.id, item)
|
||||
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
}
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
let changed = false
|
||||
for (const part of want) {
|
||||
const result = Binary.search(next, part.id, (item) => item.id)
|
||||
if (result.found) continue
|
||||
next.splice(result.index, 0, part)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return parts
|
||||
return next
|
||||
}
|
||||
|
||||
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
||||
const confirmed: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
|
||||
const current = part.get(item.message.id)
|
||||
if (found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
part.set(item.message.id, mergeParts(current, item.parts))
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: page.cursor,
|
||||
complete: page.complete,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
|
||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return [input.message]
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 0, input.message)
|
||||
return next
|
||||
})
|
||||
setStore("part", input.message.id, sortParts(input.parts))
|
||||
}
|
||||
|
||||
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return messages
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
if (!result.found) return messages
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 1)
|
||||
return next
|
||||
})
|
||||
setStore("part", (part: Record<string, Part[] | undefined>) => {
|
||||
if (!(input.messageID in part)) return part
|
||||
const next = { ...part }
|
||||
delete next[input.messageID]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
type Child = ReturnType<(typeof globalSync)["child"]>
|
||||
type Setter = Child[1]
|
||||
|
||||
const current = createMemo(() => globalSync.child(directory))
|
||||
const target = (directory?: string) => {
|
||||
if (!directory || directory === directory) return current()
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const maxDirs = 30
|
||||
const seen = new Map<string, Set<string>>()
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
complete: {} as Record<string, boolean>,
|
||||
loading: {} as Record<string, boolean>,
|
||||
})
|
||||
|
||||
const getSession = (sessionID: string) => {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
}
|
||||
|
||||
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
|
||||
const key = keyFor(directory, sessionID)
|
||||
const list = optimistic.get(key)
|
||||
if (list) {
|
||||
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
|
||||
return
|
||||
}
|
||||
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
|
||||
}
|
||||
|
||||
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (!messageID) {
|
||||
optimistic.delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
const list = optimistic.get(key)
|
||||
if (!list) return
|
||||
list.delete(messageID)
|
||||
if (list.size === 0) optimistic.delete(key)
|
||||
}
|
||||
|
||||
const getOptimistic = (directory: string, sessionID: string) => [
|
||||
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
|
||||
]
|
||||
|
||||
const seenFor = (directory: string) => {
|
||||
const existing = seen.get(directory)
|
||||
if (existing) {
|
||||
seen.delete(directory)
|
||||
seen.set(directory, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Set<string>()
|
||||
seen.set(directory, created)
|
||||
while (seen.size > maxDirs) {
|
||||
const first = seen.keys().next().value
|
||||
if (!first) break
|
||||
const stale = [...(seen.get(first) ?? [])]
|
||||
seen.delete(first)
|
||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||
evict(first, setStore, stale)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
for (const sessionID of sessionIDs) {
|
||||
clearOptimistic(directory, sessionID)
|
||||
}
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
delete draft.limit[key]
|
||||
delete draft.cursor[key]
|
||||
delete draft.complete[key]
|
||||
delete draft.loading[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
clearSessionPrefetch(directory, sessionIDs)
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
}),
|
||||
)
|
||||
clearMeta(directory, sessionIDs)
|
||||
}
|
||||
|
||||
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: seenFor(directory),
|
||||
keep: sessionID,
|
||||
limit: SESSION_CACHE_LIMIT,
|
||||
})
|
||||
evict(directory, setStore, stale)
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||
)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||
return {
|
||||
session,
|
||||
part,
|
||||
cursor,
|
||||
complete: !cursor,
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof client
|
||||
setStore: Setter
|
||||
sessionID: string
|
||||
limit: number
|
||||
before?: string
|
||||
mode?: "replace" | "prepend"
|
||||
}) => {
|
||||
const key = keyFor(input.directory, input.sessionID)
|
||||
if (meta.loading[key]) return
|
||||
|
||||
setMeta("loading", key, true)
|
||||
await fetchMessages(input)
|
||||
.then((page) => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
|
||||
for (const messageID of next.confirmed) {
|
||||
clearOptimistic(input.directory, input.sessionID, messageID)
|
||||
}
|
||||
const [store] = globalSync.child(input.directory, { bootstrap: false })
|
||||
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
|
||||
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
|
||||
if (filtered.length) input.setStore("part", p.id, filtered)
|
||||
}
|
||||
setMeta("limit", key, message.length)
|
||||
setMeta("cursor", key, next.cursor)
|
||||
setMeta("complete", key, next.complete)
|
||||
setSessionPrefetch({
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
limit: message.length,
|
||||
cursor: next.cursor,
|
||||
complete: next.complete,
|
||||
})
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
if (!tracked(input.directory, input.sessionID)) {
|
||||
delete draft.loading[key]
|
||||
return
|
||||
}
|
||||
draft.loading[key] = false
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return current()[0]
|
||||
},
|
||||
get set(): Setter {
|
||||
return current()[1]
|
||||
},
|
||||
get status() {
|
||||
return current()[0].status
|
||||
},
|
||||
get ready() {
|
||||
return current()[0].status !== "loading"
|
||||
},
|
||||
get project() {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
||||
if (match.found) return globalSync.data.project[match.index]
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
get: getSession,
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
const _directory = input.directory ?? directory
|
||||
const [, setStore] = target(input.directory)
|
||||
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
|
||||
},
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
const _directory = input.directory ?? directory
|
||||
const [, setStore] = target(input.directory)
|
||||
clearOptimistic(_directory, input.sessionID, input.messageID)
|
||||
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
|
||||
},
|
||||
},
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}) {
|
||||
const message: Message = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: { ...input.model, variant: input.variant },
|
||||
}
|
||||
const [, setStore] = target()
|
||||
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||
sessionID: input.sessionID,
|
||||
message,
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("cursor", key, seeded.cursor)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
|
||||
return runInflight(inflight, key, async () => {
|
||||
const pending = getSessionPrefetchPromise(directory, sessionID)
|
||||
if (pending) {
|
||||
await pending
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("cursor", key, seeded.cursor)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
cached && !opts?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
await Promise.all([sessionReq, messagesReq])
|
||||
})
|
||||
},
|
||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
||||
}),
|
||||
)
|
||||
},
|
||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
if (!opts?.force) return
|
||||
}
|
||||
|
||||
if (cached !== undefined) {
|
||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||
}
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightTodo, key, () =>
|
||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
globalSync.todo.set(sessionID, list)
|
||||
}),
|
||||
)
|
||||
},
|
||||
history: {
|
||||
more(sessionID: string) {
|
||||
const store = current()[0]
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (store.message[sessionID] === undefined) return false
|
||||
if (meta.limit[key] === undefined) return false
|
||||
if (meta.complete[key]) return false
|
||||
return !!meta.cursor[key]
|
||||
},
|
||||
loading(sessionID: string) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count?: number) {
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? historyMessagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
if (!before) return
|
||||
|
||||
await loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit: step,
|
||||
before,
|
||||
mode: "prepend",
|
||||
})
|
||||
},
|
||||
},
|
||||
evict(sessionID: string, _directory = directory) {
|
||||
const [, setStore] = globalSync.child(_directory)
|
||||
seenFor(_directory).delete(sessionID)
|
||||
evict(_directory, setStore, [sessionID])
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
setStore("limit", (x) => x + count)
|
||||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return current()[0].path.directory
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -422,9 +423,18 @@ function createGlobalSync() {
|
||||
|
||||
const updateConfigMutation = useMutation(() => ({
|
||||
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
|
||||
onSuccess: () => bootstrap.refetch(),
|
||||
onSuccess: () => {
|
||||
bootstrap.refetch()
|
||||
// Invalidate all provider queries so newly configured custom providers
|
||||
// appear immediately in the available provider list across all directories.
|
||||
queryClient.invalidateQueries({ queryKey: [null, "providers"] })
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
|
||||
},
|
||||
}))
|
||||
|
||||
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
|
||||
const dirSyncContextRefCounts = new Map<string, number>()
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set,
|
||||
@@ -443,6 +453,26 @@ function createGlobalSync() {
|
||||
todo: {
|
||||
set: setSessionTodo,
|
||||
},
|
||||
createDirSyncContext: (directory: string) => {
|
||||
onCleanup(() => {
|
||||
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
|
||||
if (dirSyncContextRefCounts.get(directory) === 0) {
|
||||
dirSyncContexts.delete(directory)
|
||||
dirSyncContextRefCounts.delete(directory)
|
||||
}
|
||||
})
|
||||
|
||||
const cached = dirSyncContexts.get(directory)
|
||||
if (cached) {
|
||||
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
|
||||
return cached
|
||||
}
|
||||
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
|
||||
dirSyncContexts.set(directory, ctx)
|
||||
dirSyncContextRefCounts.set(directory, 1)
|
||||
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export function trimSessions(
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const roots = all.filter((s) => !s.parentID)
|
||||
roots.sort(compareSessionRecent)
|
||||
const children = all.filter((s) => !!s.parentID)
|
||||
const base = roots.slice(0, limit)
|
||||
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
|
||||
|
||||
@@ -18,6 +18,7 @@ export type Locale =
|
||||
| "ja"
|
||||
| "pl"
|
||||
| "ru"
|
||||
| "uk"
|
||||
| "ar"
|
||||
| "no"
|
||||
| "br"
|
||||
@@ -45,6 +46,7 @@ const LOCALES: readonly Locale[] = [
|
||||
"ja",
|
||||
"pl",
|
||||
"ru",
|
||||
"uk",
|
||||
"bs",
|
||||
"ar",
|
||||
"no",
|
||||
@@ -65,6 +67,7 @@ const INTL: Record<Locale, string> = {
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
uk: "uk",
|
||||
ar: "ar",
|
||||
no: "nb-NO",
|
||||
br: "pt-BR",
|
||||
@@ -85,6 +88,7 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
ja: "language.ja",
|
||||
pl: "language.pl",
|
||||
ru: "language.ru",
|
||||
uk: "language.uk",
|
||||
ar: "language.ar",
|
||||
no: "language.no",
|
||||
br: "language.br",
|
||||
@@ -110,6 +114,7 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
|
||||
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
|
||||
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||
uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
|
||||
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||
@@ -145,6 +150,7 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
||||
{ locale: "ja", match: (language) => language.startsWith("ja") },
|
||||
{ locale: "pl", match: (language) => language.startsWith("pl") },
|
||||
{ locale: "ru", match: (language) => language.startsWith("ru") },
|
||||
{ locale: "uk", match: (language) => language.startsWith("uk") },
|
||||
{ locale: "ar", match: (language) => language.startsWith("ar") },
|
||||
{
|
||||
locale: "no",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { DesktopMenuAction } from "../desktop-menu"
|
||||
import { ServerConnection } from "./server"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
@@ -82,6 +83,9 @@ export type Platform = {
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
/** Run a desktop-only menu action from the app chrome */
|
||||
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
||||
|
||||
/** Check if an editor app exists (desktop only) */
|
||||
checkAppExists?(appName: string): Promise<boolean>
|
||||
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
getSessionPrefetchPromise,
|
||||
setSessionPrefetch,
|
||||
} from "./global-sync/session-prefetch"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
@@ -172,448 +161,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const globalSync = useGlobalSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
type Child = ReturnType<(typeof globalSync)["child"]>
|
||||
type Setter = Child[1]
|
||||
|
||||
const current = createMemo(() => globalSync.child(sdk.directory))
|
||||
const target = (directory?: string) => {
|
||||
if (!directory || directory === sdk.directory) return current()
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const maxDirs = 30
|
||||
const seen = new Map<string, Set<string>>()
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
complete: {} as Record<string, boolean>,
|
||||
loading: {} as Record<string, boolean>,
|
||||
})
|
||||
|
||||
const getSession = (sessionID: string) => {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
}
|
||||
|
||||
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
|
||||
const key = keyFor(directory, sessionID)
|
||||
const list = optimistic.get(key)
|
||||
if (list) {
|
||||
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
|
||||
return
|
||||
}
|
||||
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
|
||||
}
|
||||
|
||||
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (!messageID) {
|
||||
optimistic.delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
const list = optimistic.get(key)
|
||||
if (!list) return
|
||||
list.delete(messageID)
|
||||
if (list.size === 0) optimistic.delete(key)
|
||||
}
|
||||
|
||||
const getOptimistic = (directory: string, sessionID: string) => [
|
||||
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
|
||||
]
|
||||
|
||||
const seenFor = (directory: string) => {
|
||||
const existing = seen.get(directory)
|
||||
if (existing) {
|
||||
seen.delete(directory)
|
||||
seen.set(directory, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Set<string>()
|
||||
seen.set(directory, created)
|
||||
while (seen.size > maxDirs) {
|
||||
const first = seen.keys().next().value
|
||||
if (!first) break
|
||||
const stale = [...(seen.get(first) ?? [])]
|
||||
seen.delete(first)
|
||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||
evict(first, setStore, stale)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
for (const sessionID of sessionIDs) {
|
||||
clearOptimistic(directory, sessionID)
|
||||
}
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
delete draft.limit[key]
|
||||
delete draft.cursor[key]
|
||||
delete draft.complete[key]
|
||||
delete draft.loading[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
clearSessionPrefetch(directory, sessionIDs)
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
}),
|
||||
)
|
||||
clearMeta(directory, sessionIDs)
|
||||
}
|
||||
|
||||
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: seenFor(directory),
|
||||
keep: sessionID,
|
||||
limit: SESSION_CACHE_LIMIT,
|
||||
})
|
||||
evict(directory, setStore, stale)
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: {
|
||||
client: typeof sdk.client
|
||||
sessionID: string
|
||||
limit: number
|
||||
before?: string
|
||||
}) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||
)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||
return {
|
||||
session,
|
||||
part,
|
||||
cursor,
|
||||
complete: !cursor,
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof sdk.client
|
||||
setStore: Setter
|
||||
sessionID: string
|
||||
limit: number
|
||||
before?: string
|
||||
mode?: "replace" | "prepend"
|
||||
}) => {
|
||||
const key = keyFor(input.directory, input.sessionID)
|
||||
if (meta.loading[key]) return
|
||||
|
||||
setMeta("loading", key, true)
|
||||
await fetchMessages(input)
|
||||
.then((page) => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
|
||||
for (const messageID of next.confirmed) {
|
||||
clearOptimistic(input.directory, input.sessionID, messageID)
|
||||
}
|
||||
const [store] = globalSync.child(input.directory, { bootstrap: false })
|
||||
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
|
||||
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
|
||||
if (filtered.length) input.setStore("part", p.id, filtered)
|
||||
}
|
||||
setMeta("limit", key, message.length)
|
||||
setMeta("cursor", key, next.cursor)
|
||||
setMeta("complete", key, next.complete)
|
||||
setSessionPrefetch({
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
limit: message.length,
|
||||
cursor: next.cursor,
|
||||
complete: next.complete,
|
||||
})
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
if (!tracked(input.directory, input.sessionID)) {
|
||||
delete draft.loading[key]
|
||||
return
|
||||
}
|
||||
draft.loading[key] = false
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return current()[0]
|
||||
},
|
||||
get set(): Setter {
|
||||
return current()[1]
|
||||
},
|
||||
get status() {
|
||||
return current()[0].status
|
||||
},
|
||||
get ready() {
|
||||
return current()[0].status !== "loading"
|
||||
},
|
||||
get project() {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
||||
if (match.found) return globalSync.data.project[match.index]
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
get: getSession,
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
const directory = input.directory ?? sdk.directory
|
||||
const [, setStore] = target(input.directory)
|
||||
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
|
||||
},
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
const directory = input.directory ?? sdk.directory
|
||||
const [, setStore] = target(input.directory)
|
||||
clearOptimistic(directory, input.sessionID, input.messageID)
|
||||
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
|
||||
},
|
||||
},
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}) {
|
||||
const message: Message = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: { ...input.model, variant: input.variant },
|
||||
}
|
||||
const [, setStore] = target()
|
||||
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||
sessionID: input.sessionID,
|
||||
message,
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("cursor", key, seeded.cursor)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
|
||||
return runInflight(inflight, key, async () => {
|
||||
const pending = getSessionPrefetchPromise(directory, sessionID)
|
||||
if (pending) {
|
||||
await pending
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("cursor", key, seeded.cursor)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
cached && !opts?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
await Promise.all([sessionReq, messagesReq])
|
||||
})
|
||||
},
|
||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
||||
}),
|
||||
)
|
||||
},
|
||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
if (!opts?.force) return
|
||||
}
|
||||
|
||||
if (cached !== undefined) {
|
||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||
}
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightTodo, key, () =>
|
||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
globalSync.todo.set(sessionID, list)
|
||||
}),
|
||||
)
|
||||
},
|
||||
history: {
|
||||
more(sessionID: string) {
|
||||
const store = current()[0]
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
if (store.message[sessionID] === undefined) return false
|
||||
if (meta.limit[key] === undefined) return false
|
||||
if (meta.complete[key]) return false
|
||||
return !!meta.cursor[key]
|
||||
},
|
||||
loading(sessionID: string) {
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count?: number) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? historyMessagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
if (!before) return
|
||||
|
||||
await loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit: step,
|
||||
before,
|
||||
mode: "prepend",
|
||||
})
|
||||
},
|
||||
},
|
||||
evict(sessionID: string, directory = sdk.directory) {
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
seenFor(directory).delete(sessionID)
|
||||
evict(directory, setStore, [sessionID])
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
setStore("limit", (x) => x + count)
|
||||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return current()[0].path.directory
|
||||
},
|
||||
}
|
||||
return globalSync.createDirSyncContext(sdk.directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
export type DesktopMenuPlatform = "macos" | "windows"
|
||||
|
||||
export type DesktopMenuAction =
|
||||
| "app.checkForUpdates"
|
||||
| "app.relaunch"
|
||||
| "edit.undo"
|
||||
| "edit.redo"
|
||||
| "edit.cut"
|
||||
| "edit.copy"
|
||||
| "edit.paste"
|
||||
| "edit.delete"
|
||||
| "edit.selectAll"
|
||||
| "view.reload"
|
||||
| "view.toggleDevTools"
|
||||
| "view.resetZoom"
|
||||
| "view.zoomIn"
|
||||
| "view.zoomOut"
|
||||
| "view.toggleFullscreen"
|
||||
| "window.new"
|
||||
| "window.close"
|
||||
| "window.minimize"
|
||||
| "window.toggleMaximize"
|
||||
|
||||
export type DesktopMenuRole =
|
||||
| "about"
|
||||
| "close"
|
||||
| "copy"
|
||||
| "cut"
|
||||
| "hide"
|
||||
| "hideOthers"
|
||||
| "paste"
|
||||
| "quit"
|
||||
| "redo"
|
||||
| "reload"
|
||||
| "resetZoom"
|
||||
| "selectAll"
|
||||
| "toggleDevTools"
|
||||
| "togglefullscreen"
|
||||
| "undo"
|
||||
| "unhide"
|
||||
| "windowMenu"
|
||||
| "zoomIn"
|
||||
| "zoomOut"
|
||||
|
||||
export type DesktopMenuItem = {
|
||||
type: "item"
|
||||
label?: string
|
||||
command?: string
|
||||
action?: DesktopMenuAction
|
||||
role?: DesktopMenuRole
|
||||
href?: string
|
||||
accelerator?: Partial<Record<DesktopMenuPlatform, string>>
|
||||
enabled?: "updater"
|
||||
platforms?: DesktopMenuPlatform[]
|
||||
}
|
||||
|
||||
export type DesktopMenuSeparator = {
|
||||
type: "separator"
|
||||
platforms?: DesktopMenuPlatform[]
|
||||
}
|
||||
|
||||
export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
|
||||
|
||||
export type DesktopMenu = {
|
||||
id: string
|
||||
label: string
|
||||
role?: DesktopMenuRole
|
||||
items?: DesktopMenuEntry[]
|
||||
platforms?: DesktopMenuPlatform[]
|
||||
}
|
||||
|
||||
export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{
|
||||
id: "app",
|
||||
label: "OpenCode",
|
||||
platforms: ["macos"],
|
||||
items: [
|
||||
{ type: "item", role: "about" },
|
||||
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
|
||||
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
||||
{ type: "item", label: "Reload Webview", action: "view.reload" },
|
||||
{ type: "item", label: "Restart", action: "app.relaunch" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", role: "hide" },
|
||||
{ type: "item", role: "hideOthers" },
|
||||
{ type: "item", role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", role: "quit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "file",
|
||||
label: "File",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
label: "New Session",
|
||||
command: "session.new",
|
||||
accelerator: { macos: "Shift+Cmd+S" },
|
||||
},
|
||||
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
|
||||
{
|
||||
type: "item",
|
||||
label: "Settings",
|
||||
command: "settings.open",
|
||||
accelerator: { windows: "Ctrl+," },
|
||||
platforms: ["windows"],
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: "New Window",
|
||||
action: "window.new",
|
||||
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
items: [
|
||||
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
|
||||
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
|
||||
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
|
||||
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
|
||||
{ type: "item", label: "Delete", action: "edit.delete" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Select All",
|
||||
action: "edit.selectAll",
|
||||
role: "selectAll",
|
||||
accelerator: { windows: "Ctrl+A" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
label: "View",
|
||||
items: [
|
||||
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
|
||||
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
|
||||
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
|
||||
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Actual Size",
|
||||
action: "view.resetZoom",
|
||||
role: "resetZoom",
|
||||
accelerator: { windows: "Ctrl+0" },
|
||||
},
|
||||
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
|
||||
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
label: "Go",
|
||||
items: [
|
||||
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Previous Project",
|
||||
command: "project.previous",
|
||||
accelerator: { macos: "Cmd+Option+Up" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: "Next Project",
|
||||
command: "project.next",
|
||||
accelerator: { macos: "Cmd+Option+Down" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "window",
|
||||
label: "Window",
|
||||
role: "windowMenu",
|
||||
items: [
|
||||
{ type: "item", label: "Minimize", action: "window.minimize" },
|
||||
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Close Window", action: "window.close" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help",
|
||||
items: [
|
||||
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
|
||||
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Share Feedback",
|
||||
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: "Report a Bug",
|
||||
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
|
||||
return !item.platforms || item.platforms.includes(platform)
|
||||
}
|
||||
@@ -416,6 +416,7 @@ export const dict = {
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ import { dict as ko } from "./ko"
|
||||
import { dict as no } from "./no"
|
||||
import { dict as pl } from "./pl"
|
||||
import { dict as ru } from "./ru"
|
||||
import { dict as uk } from "./uk"
|
||||
import { dict as th } from "./th"
|
||||
import { dict as zh } from "./zh"
|
||||
import { dict as zht } from "./zht"
|
||||
import { dict as tr } from "./tr"
|
||||
|
||||
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh, zht]
|
||||
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, uk, th, tr, zh, zht]
|
||||
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
|
||||
|
||||
describe("i18n parity", () => {
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
export const dict = {
|
||||
"command.category.suggested": "Рекомендовані",
|
||||
"command.category.view": "Вигляд",
|
||||
"command.category.project": "Проєкт",
|
||||
"command.category.provider": "Провайдер",
|
||||
"command.category.server": "Сервер",
|
||||
"command.category.session": "Сесія",
|
||||
"command.category.theme": "Тема",
|
||||
"command.category.language": "Мова",
|
||||
"command.category.file": "Файл",
|
||||
"command.category.context": "Контекст",
|
||||
"command.category.terminal": "Термінал",
|
||||
"command.category.model": "Модель",
|
||||
"command.category.mcp": "MCP",
|
||||
"command.category.agent": "Агент",
|
||||
"command.category.permissions": "Дозволи",
|
||||
"command.category.workspace": "Робоча область",
|
||||
"command.category.settings": "Налаштування",
|
||||
|
||||
"theme.scheme.system": "Системна",
|
||||
"theme.scheme.light": "Світла",
|
||||
"theme.scheme.dark": "Темна",
|
||||
|
||||
"command.sidebar.toggle": "Перемкнути бічну панель",
|
||||
"command.project.open": "Відкрити проєкт",
|
||||
"command.project.previous": "Попередній проєкт",
|
||||
"command.project.next": "Наступний проєкт",
|
||||
"command.project.index": "Перемкнути на проєкт {{index}}",
|
||||
"command.provider.connect": "Підключити провайдера",
|
||||
"command.server.switch": "Перемкнути сервер",
|
||||
"command.settings.open": "Відкрити налаштування",
|
||||
"command.session.previous": "Попередня сесія",
|
||||
"command.session.next": "Наступна сесія",
|
||||
"command.session.previous.unseen": "Попередня непрочитана сесія",
|
||||
"command.session.next.unseen": "Наступна непрочитана сесія",
|
||||
"command.session.archive": "Архівувати сесію",
|
||||
|
||||
"command.palette": "Палітра команд",
|
||||
|
||||
"command.theme.cycle": "Перемкнути тему",
|
||||
"command.theme.set": "Використати тему: {{theme}}",
|
||||
"command.theme.scheme.cycle": "Перемкнути кольорову схему",
|
||||
"command.theme.scheme.set": "Використати кольорову схему: {{scheme}}",
|
||||
|
||||
"command.language.cycle": "Перемкнути мову",
|
||||
"command.language.set": "Використати мову: {{language}}",
|
||||
|
||||
"command.session.new": "Нова сесія",
|
||||
"command.file.open": "Відкрити файл",
|
||||
"command.tab.close": "Закрити вкладку",
|
||||
"command.context.addSelection": "Додати виділення до контексту",
|
||||
"command.context.addSelection.description": "Додати вибрані рядки з поточного файлу",
|
||||
"command.input.focus": "Фокус на полі введення",
|
||||
"command.terminal.toggle": "Перемкнути термінал",
|
||||
"command.fileTree.toggle": "Перемкнути дерево файлів",
|
||||
"command.review.toggle": "Перемкнути огляд",
|
||||
"command.terminal.new": "Новий термінал",
|
||||
"command.terminal.new.description": "Створити нову вкладку термінала",
|
||||
"command.steps.toggle": "Перемкнути кроки",
|
||||
"command.steps.toggle.description": "Показати або приховати кроки для поточного повідомлення",
|
||||
"command.message.previous": "Попереднє повідомлення",
|
||||
"command.message.previous.description": "Перейти до попереднього повідомлення користувача",
|
||||
"command.message.next": "Наступне повідомлення",
|
||||
"command.message.next.description": "Перейти до наступного повідомлення користувача",
|
||||
"command.model.choose": "Вибрати модель",
|
||||
"command.model.choose.description": "Вибрати іншу модель",
|
||||
"command.mcp.toggle": "Перемкнути MCP",
|
||||
"command.mcp.toggle.description": "Перемкнути MCP",
|
||||
"command.agent.cycle": "Перемкнути агента",
|
||||
"command.agent.cycle.description": "Перемкнути на наступного агента",
|
||||
"command.agent.cycle.reverse": "Перемкнути агента в зворотному напрямку",
|
||||
"command.agent.cycle.reverse.description": "Перемкнути на попереднього агента",
|
||||
"command.model.variant.cycle": "Перемкнути рівень мислення",
|
||||
"command.model.variant.cycle.description": "Перемкнути на наступний рівень зусилля",
|
||||
"command.prompt.mode.shell": "Команда",
|
||||
"command.prompt.mode.normal": "Запит",
|
||||
"command.permissions.autoaccept.enable": "Автоматично приймати дозволи",
|
||||
"command.permissions.autoaccept.disable": "Зупинити автоматичне прийняття дозволів",
|
||||
"command.workspace.toggle": "Перемкнути робочі області",
|
||||
"command.workspace.toggle.description": "Увімкнути або вимкнути декілька робочих областей на бічній панелі",
|
||||
"command.session.undo": "Скасувати",
|
||||
"command.session.undo.description": "Скасувати останнє повідомлення",
|
||||
"command.session.redo": "Повторити",
|
||||
"command.session.redo.description": "Повторити останнє скасоване повідомлення",
|
||||
"command.session.compact": "Стиснути сесію",
|
||||
"command.session.compact.description": "Підсумувати сесію, щоб зменшити розмір контексту",
|
||||
"command.session.fork": "Відгалузити від повідомлення",
|
||||
"command.session.fork.description": "Створити нову сесію з попереднього повідомлення",
|
||||
"command.session.share": "Поділитися сесією",
|
||||
"command.session.share.description": "Поділитися цією сесією та скопіювати URL у буфер обміну",
|
||||
"command.session.unshare": "Припинити поширення сесії",
|
||||
"command.session.unshare.description": "Припинити поширення цієї сесії",
|
||||
|
||||
"palette.search.placeholder": "Пошук файлів, команд і сесій",
|
||||
"palette.empty": "Результатів не знайдено",
|
||||
"palette.group.commands": "Команди",
|
||||
"palette.group.files": "Файли",
|
||||
|
||||
"dialog.provider.search.placeholder": "Пошук провайдерів",
|
||||
"dialog.provider.empty": "Провайдерів не знайдено",
|
||||
"dialog.provider.group.popular": "Популярні",
|
||||
"dialog.provider.group.other": "Інші",
|
||||
"dialog.provider.tag.recommended": "Рекомендовані",
|
||||
"dialog.provider.opencode.note": "Відібрані моделі, включаючи Claude, GPT, Gemini та інші",
|
||||
"dialog.provider.opencode.tagline": "Надійні оптимізовані моделі",
|
||||
"dialog.provider.opencodeGo.tagline": "Недорога підписка для всіх",
|
||||
"dialog.provider.anthropic.note": "Прямий доступ до моделей Claude, включаючи Pro та Max",
|
||||
"dialog.provider.copilot.note": "Моделі AI для допомоги в кодуванні через GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Моделі GPT для швидких і універсальних завдань AI",
|
||||
"dialog.provider.google.note": "Моделі Gemini для швидких структурованих відповідей",
|
||||
"dialog.provider.openrouter.note": "Доступ до всіх підтримуваних моделей від одного провайдера",
|
||||
"dialog.provider.vercel.note": "Уніфікований доступ до моделей AI з інтелектуальною маршрутизацією",
|
||||
|
||||
"dialog.model.select.title": "Вибрати модель",
|
||||
"dialog.model.search.placeholder": "Пошук моделей",
|
||||
"dialog.model.empty": "Немає результатів моделей",
|
||||
"dialog.model.manage": "Керувати моделями",
|
||||
"dialog.model.manage.description": "Налаштуйте, які моделі відображатимуться у виборі моделей.",
|
||||
"dialog.model.manage.provider.toggle": "Перемкнути всі моделі {{provider}}",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Безкоштовні моделі від OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Додати більше моделей від популярних провайдерів",
|
||||
|
||||
"dialog.provider.viewAll": "Показати більше провайдерів",
|
||||
|
||||
"provider.connect.title": "Підключити {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Увійти з Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Виберіть спосіб входу для {{provider}}.",
|
||||
"provider.connect.method.apiKey": "Ключ API",
|
||||
"provider.connect.status.inProgress": "Авторизація виконується...",
|
||||
"provider.connect.status.waiting": "Очікування авторизації...",
|
||||
"provider.connect.status.failed": "Авторизація не вдалася: {{error}}",
|
||||
"provider.connect.apiKey.description":
|
||||
"Введіть ключ API {{provider}}, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
|
||||
"provider.connect.apiKey.label": "Ключ API {{provider}}",
|
||||
"provider.connect.apiKey.placeholder": "Ключ API",
|
||||
"provider.connect.apiKey.required": "Ключ API обов'язковий",
|
||||
"provider.connect.opencodeZen.line1":
|
||||
"OpenCode Zen надає доступ до відібраного набору надійних оптимізованих моделей для агентів кодування.",
|
||||
"provider.connect.opencodeZen.line2":
|
||||
"З одним ключем API ви отримаєте доступ до таких моделей, як Claude, GPT, Gemini, GLM та інших.",
|
||||
"provider.connect.opencodeZen.visit.prefix": "Відвідайте ",
|
||||
"provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
|
||||
"provider.connect.opencodeZen.visit.suffix": ", щоб отримати ключ API.",
|
||||
"provider.connect.oauth.code.visit.prefix": "Відвідайте ",
|
||||
"provider.connect.oauth.code.visit.link": "це посилання",
|
||||
"provider.connect.oauth.code.visit.suffix":
|
||||
", щоб отримати код авторизації, підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
|
||||
"provider.connect.oauth.code.label": "Код авторизації {{method}}",
|
||||
"provider.connect.oauth.code.placeholder": "Код авторизації",
|
||||
"provider.connect.oauth.code.required": "Код авторизації обов'язковий",
|
||||
"provider.connect.oauth.code.invalid": "Недійсний код авторизації",
|
||||
"provider.connect.oauth.auto.visit.prefix": "Відвідайте ",
|
||||
"provider.connect.oauth.auto.visit.link": "це посилання",
|
||||
"provider.connect.oauth.auto.visit.suffix":
|
||||
" і введіть код нижче, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
|
||||
"provider.connect.oauth.auto.confirmationCode": "Код підтвердження",
|
||||
"provider.connect.toast.connected.title": "{{provider}} підключено",
|
||||
"provider.connect.toast.connected.description": "Моделі {{provider}} тепер доступні для використання.",
|
||||
|
||||
"provider.custom.title": "Користувацький провайдер",
|
||||
"provider.custom.description.prefix": "Налаштуйте провайдера, сумісного з OpenAI. Перегляньте ",
|
||||
"provider.custom.description.link": "документацію з налаштування провайдера",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "ID провайдера",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Малі літери, цифри, дефіси або підкреслення",
|
||||
"provider.custom.field.name.label": "Відображувана назва",
|
||||
"provider.custom.field.name.placeholder": "Мій AI Провайдер",
|
||||
"provider.custom.field.baseURL.label": "Базовий URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "Ключ API",
|
||||
"provider.custom.field.apiKey.placeholder": "Ключ API",
|
||||
"provider.custom.field.apiKey.description":
|
||||
"Необов'язково. Залиште порожнім, якщо ви керуєте авторизацією через заголовки.",
|
||||
"provider.custom.models.label": "Моделі",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Назва",
|
||||
"provider.custom.models.name.placeholder": "Відображувана назва",
|
||||
"provider.custom.models.remove": "Видалити модель",
|
||||
"provider.custom.models.add": "Додати модель",
|
||||
"provider.custom.headers.label": "Заголовки (необов'язково)",
|
||||
"provider.custom.headers.key.label": "Заголовок",
|
||||
"provider.custom.headers.key.placeholder": "Назва-Заголовка",
|
||||
"provider.custom.headers.value.label": "Значення",
|
||||
"provider.custom.headers.value.placeholder": "значення",
|
||||
"provider.custom.headers.remove": "Видалити заголовок",
|
||||
"provider.custom.headers.add": "Додати заголовок",
|
||||
"provider.custom.error.providerID.required": "ID провайдера обов'язкове",
|
||||
"provider.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення",
|
||||
"provider.custom.error.providerID.exists": "ID провайдера вже існує",
|
||||
"provider.custom.error.name.required": "Відображувана назва обов'язкова",
|
||||
"provider.custom.error.baseURL.required": "Базовий URL обов'язковий",
|
||||
"provider.custom.error.baseURL.format": "Має починатися з http:// або https://",
|
||||
"provider.custom.error.required": "Обов'язково",
|
||||
"provider.custom.error.duplicate": "Дублікат",
|
||||
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} відключено",
|
||||
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
|
||||
|
||||
"model.tag.free": "Безкоштовно",
|
||||
"model.tag.latest": "Остання",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
"model.provider.google": "Google",
|
||||
"model.provider.xai": "xAI",
|
||||
"model.provider.meta": "Meta",
|
||||
"model.input.text": "текст",
|
||||
"model.input.image": "зображення",
|
||||
"model.input.audio": "аудіо",
|
||||
"model.input.video": "відео",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "Дозволяє: {{inputs}}",
|
||||
"model.tooltip.reasoning.allowed": "Підтримує мислення",
|
||||
"model.tooltip.reasoning.none": "Без мислення",
|
||||
"model.tooltip.context": "Ліміт контексту {{limit}}",
|
||||
|
||||
"common.search.placeholder": "Пошук",
|
||||
"common.goBack": "Назад",
|
||||
"common.goForward": "Вперед",
|
||||
"common.loading": "Завантаження",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Скасувати",
|
||||
"common.open": "Відкрити",
|
||||
"common.connect": "Підключити",
|
||||
"common.disconnect": "Відключити",
|
||||
"common.continue": "Продовжити",
|
||||
"common.submit": "Надіслати",
|
||||
"common.save": "Зберегти",
|
||||
"common.saving": "Збереження...",
|
||||
"common.default": "За замовчуванням",
|
||||
"common.attachment": "вкладення",
|
||||
|
||||
"prompt.placeholder.shell": "Введіть команду термінала... {{example}}",
|
||||
"prompt.placeholder.normal": 'Запитайте що завгодно... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Запитайте що завгодно...",
|
||||
"prompt.placeholder.summarizeComments": "Підсумувати коментарі…",
|
||||
"prompt.placeholder.summarizeComment": "Підсумувати коментар…",
|
||||
"prompt.mode.shell": "Команда",
|
||||
"prompt.mode.normal": "Запит",
|
||||
"prompt.mode.shell.exit": "esc для виходу",
|
||||
"session.child.promptDisabled": "Сесії підагентів не можна надсилати запити.",
|
||||
"session.child.backToParent": "Назад до основної сесії.",
|
||||
|
||||
"prompt.example.1": "Виправити TODO у коді",
|
||||
"prompt.example.2": "Який технологічний стек цього проєкту?",
|
||||
"prompt.example.3": "Виправити зламані тести",
|
||||
"prompt.example.4": "Пояснити, як працює автентифікація",
|
||||
"prompt.example.5": "Знайти та виправити вразливості безпеки",
|
||||
"prompt.example.6": "Додати модульні тести для сервісу користувача",
|
||||
"prompt.example.7": "Рефакторити цю функцію, щоб зробити її більш читабельною",
|
||||
"prompt.example.8": "Що означає ця помилка?",
|
||||
"prompt.example.9": "Допоможіть мені налагодити цю проблему",
|
||||
"prompt.example.10": "Згенерувати документацію API",
|
||||
"prompt.example.11": "Оптимізувати запити до бази даних",
|
||||
"prompt.example.12": "Додати валідацію введення",
|
||||
"prompt.example.13": "Створити новий компонент для...",
|
||||
"prompt.example.14": "Як розгорнути цей проєкт?",
|
||||
"prompt.example.15": "Перевірити мій код на відповідність найкращим практикам",
|
||||
"prompt.example.16": "Додати обробку помилок до цієї функції",
|
||||
"prompt.example.17": "Пояснити цей регулярний вираз",
|
||||
"prompt.example.18": "Конвертувати це в TypeScript",
|
||||
"prompt.example.19": "Додати логування по всьому коду",
|
||||
"prompt.example.20": "Які залежності застарілі?",
|
||||
"prompt.example.21": "Допоможіть написати скрипт міграції",
|
||||
"prompt.example.22": "Реалізувати кешування для цього ендпоінта",
|
||||
"prompt.example.23": "Додати посторінкову навігацію до цього списку",
|
||||
"prompt.example.24": "Створити команду CLI для...",
|
||||
"prompt.example.25": "Як тут працюють змінні середовища?",
|
||||
|
||||
"prompt.popover.emptyResults": "Немає відповідних результатів",
|
||||
"prompt.popover.emptyCommands": "Немає відповідних команд",
|
||||
"prompt.dropzone.label": "Перетягніть сюди зображення, PDF або текстові файли",
|
||||
"prompt.dropzone.file.label": "Перетягніть, щоб @згадати файл",
|
||||
"prompt.slash.badge.custom": "користувацький",
|
||||
"prompt.slash.badge.skill": "навичка",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "активний",
|
||||
"prompt.context.includeActiveFile": "Включити активний файл",
|
||||
"prompt.context.removeActiveFile": "Видалити активний файл з контексту",
|
||||
"prompt.context.removeFile": "Видалити файл з контексту",
|
||||
"prompt.action.attachFile": "Додати файли",
|
||||
"prompt.attachment.remove": "Видалити вкладення",
|
||||
"prompt.action.send": "Надіслати",
|
||||
"prompt.action.stop": "Зупинити",
|
||||
|
||||
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
|
||||
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
|
||||
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
|
||||
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Не вдалося створити робоче дерево",
|
||||
"prompt.toast.sessionCreateFailed.title": "Не вдалося створити сесію",
|
||||
"prompt.toast.shellSendFailed.title": "Не вдалося надіслати команду термінала",
|
||||
"prompt.toast.commandSendFailed.title": "Не вдалося надіслати команду",
|
||||
"prompt.toast.promptSendFailed.title": "Не вдалося надіслати запит",
|
||||
"prompt.toast.promptSendFailed.description": "Не вдалося отримати сесію",
|
||||
|
||||
"dialog.mcp.title": "MCP",
|
||||
"dialog.mcp.description": "{{enabled}} з {{total}} увімкнено",
|
||||
"dialog.mcp.empty": "MCP не налаштовано",
|
||||
|
||||
"dialog.lsp.empty": "LSP автоматично виявлені за типами файлів",
|
||||
"dialog.plugins.empty": "Плагіни налаштовані в opencode.json",
|
||||
|
||||
"mcp.status.connected": "підключено",
|
||||
"mcp.status.failed": "помилка",
|
||||
"mcp.status.needs_auth": "потрібна авторизація",
|
||||
"mcp.status.disabled": "вимкнено",
|
||||
"mcp.auth.clickToAuthenticate": "Натисніть для автентифікації",
|
||||
|
||||
"dialog.fork.empty": "Немає повідомлень для відгалуження",
|
||||
|
||||
"dialog.directory.search.placeholder": "Пошук папок",
|
||||
"dialog.directory.empty": "Папок не знайдено",
|
||||
|
||||
"app.server.unreachable": "Не вдалося досягти {{server}}",
|
||||
"app.server.retrying": "Автоматичне повторення...",
|
||||
"app.server.otherServers": "Інші сервери",
|
||||
|
||||
"dialog.server.title": "Сервери",
|
||||
"dialog.server.description": "Перемкніть сервер OpenCode, до якого підключається ця програма.",
|
||||
"dialog.server.search.placeholder": "Пошук серверів",
|
||||
"dialog.server.empty": "Ще немає серверів",
|
||||
"dialog.server.add.title": "Додати сервер",
|
||||
"dialog.server.add.url": "Адреса сервера",
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Не вдалося підключитися до сервера",
|
||||
"dialog.server.add.checking": "Перевірка...",
|
||||
"dialog.server.add.button": "Додати сервер",
|
||||
"dialog.server.add.name": "Назва сервера (необов'язково)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Ім'я користувача (необов'язково)",
|
||||
"dialog.server.add.usernamePlaceholder": "ім'я користувача",
|
||||
"dialog.server.add.password": "Пароль (необов'язково)",
|
||||
"dialog.server.add.passwordPlaceholder": "пароль",
|
||||
"dialog.server.edit.title": "Редагувати сервер",
|
||||
"dialog.server.default.title": "Сервер за замовчуванням",
|
||||
"dialog.server.default.description":
|
||||
"Підключатися до цього сервера під час запуску програми замість запуску локального сервера. Потребує перезапуску.",
|
||||
"dialog.server.default.none": "Сервер не вибрано",
|
||||
"dialog.server.default.set": "Встановити поточний сервер як сервер за замовчуванням",
|
||||
"dialog.server.default.clear": "Очистити",
|
||||
"dialog.server.action.remove": "Видалити сервер",
|
||||
|
||||
"dialog.server.menu.edit": "Редагувати",
|
||||
"dialog.server.menu.default": "Встановити за замовчуванням",
|
||||
"dialog.server.menu.defaultRemove": "Видалити за замовчуванням",
|
||||
"dialog.server.menu.delete": "Видалити",
|
||||
"dialog.server.current": "Поточний сервер",
|
||||
"dialog.server.status.default": "За замовчуванням",
|
||||
"server.row.noUsername": "без імені користувача",
|
||||
|
||||
"dialog.project.edit.title": "Редагувати проєкт",
|
||||
"dialog.project.edit.name": "Назва",
|
||||
"dialog.project.edit.icon": "Іконка",
|
||||
"dialog.project.edit.icon.alt": "Іконка проєкту",
|
||||
"dialog.project.edit.icon.hint": "Натисніть або перетягніть зображення",
|
||||
"dialog.project.edit.icon.recommended": "Рекомендовано: 128x128px",
|
||||
"dialog.project.edit.color": "Колір",
|
||||
"dialog.project.edit.color.select": "Вибрати колір {{color}}",
|
||||
"dialog.project.edit.worktree.startup": "Скрипт запуску робочої області",
|
||||
"dialog.project.edit.worktree.startup.description": "Виконується після створення нової робочої області (worktree).",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "напр. bun install",
|
||||
|
||||
"dialog.releaseNotes.action.getStarted": "Розпочати",
|
||||
"dialog.releaseNotes.action.next": "Далі",
|
||||
"dialog.releaseNotes.action.hideFuture": "Не показувати це в майбутньому",
|
||||
"dialog.releaseNotes.media.alt": "Попередній перегляд релізу",
|
||||
|
||||
"context.breakdown.title": "Розподіл контексту",
|
||||
"context.breakdown.note":
|
||||
'Приблизний розподіл вхідних токенів. "Інше" включає визначення інструментів і накладні витрати.',
|
||||
"context.breakdown.system": "Система",
|
||||
"context.breakdown.user": "Користувач",
|
||||
"context.breakdown.assistant": "Асистент",
|
||||
"context.breakdown.tool": "Виклики інструментів",
|
||||
"context.breakdown.other": "Інше",
|
||||
|
||||
"context.systemPrompt.title": "Системний запит",
|
||||
"context.rawMessages.title": "Сировинні повідомлення",
|
||||
|
||||
"context.stats.session": "Сесія",
|
||||
"context.stats.messages": "Повідомлення",
|
||||
"context.stats.provider": "Провайдер",
|
||||
"context.stats.model": "Модель",
|
||||
"context.stats.limit": "Ліміт контексту",
|
||||
"context.stats.totalTokens": "Всього токенів",
|
||||
"context.stats.usage": "Використання",
|
||||
"context.stats.inputTokens": "Вхідні токени",
|
||||
"context.stats.outputTokens": "Вихідні токени",
|
||||
"context.stats.reasoningTokens": "Токени мислення",
|
||||
"context.stats.cacheTokens": "Токени кешу (читання/запис)",
|
||||
"context.stats.userMessages": "Повідомлення користувача",
|
||||
"context.stats.assistantMessages": "Повідомлення асистента",
|
||||
"context.stats.totalCost": "Загальна вартість",
|
||||
"context.stats.sessionCreated": "Сесію створено",
|
||||
"context.stats.lastActivity": "Остання активність",
|
||||
|
||||
"context.usage.tokens": "Токени",
|
||||
"context.usage.usage": "Використання",
|
||||
"context.usage.cost": "Вартість",
|
||||
"context.usage.clickToView": "Натисніть, щоб переглянути контекст",
|
||||
"context.usage.view": "Переглянути використання контексту",
|
||||
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.uk": "Українська",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Мова",
|
||||
"toast.language.description": "Перемкнено на {{language}}",
|
||||
|
||||
"toast.theme.title": "Тему змінено",
|
||||
"toast.scheme.title": "Кольорова схема",
|
||||
|
||||
"toast.workspace.enabled.title": "Робочі області увімкнено",
|
||||
"toast.workspace.enabled.description": "Кілька робочих дерев тепер відображаються на бічній панелі",
|
||||
"toast.workspace.disabled.title": "Робочі області вимкнено",
|
||||
"toast.workspace.disabled.description": "Тільки головне робоче дерево відображається на бічній панелі",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Автоматичне прийняття дозволів",
|
||||
"toast.permissions.autoaccept.on.description": "Запити дозволів будуть автоматично схвалюватися",
|
||||
"toast.permissions.autoaccept.off.title": "Автоматичне прийняття дозволів зупинено",
|
||||
"toast.permissions.autoaccept.off.description": "Запити дозволів вимагатимуть схвалення",
|
||||
|
||||
"toast.model.none.title": "Модель не вибрано",
|
||||
"toast.model.none.description": "Підключіть провайдера, щоб підсумувати цю сесію",
|
||||
|
||||
"toast.file.loadFailed.title": "Не вдалося завантажити файл",
|
||||
"toast.file.listFailed.title": "Не вдалося отримати список файлів",
|
||||
|
||||
"toast.context.noLineSelection.title": "Не вибрано рядків",
|
||||
"toast.context.noLineSelection.description": "Спочатку виберіть діапазон рядків у вкладці файлу.",
|
||||
|
||||
"toast.session.share.copyFailed.title": "Не вдалося скопіювати URL у буфер обміну",
|
||||
"toast.session.share.success.title": "Сесію опубліковано",
|
||||
"toast.session.share.success.description": "Посилання скопійовано в буфер обміну!",
|
||||
"toast.session.share.failed.title": "Не вдалося опублікувати сесію",
|
||||
"toast.session.share.failed.description": "Під час публікації сесії сталася помилка",
|
||||
|
||||
"toast.session.unshare.success.title": "Поширення сесії припинено",
|
||||
"toast.session.unshare.success.description": "Поширення сесії успішно припинено!",
|
||||
"toast.session.unshare.failed.title": "Не вдалося припинити поширення сесії",
|
||||
"toast.session.unshare.failed.description": "Під час припинення поширення сесії сталася помилка",
|
||||
|
||||
"toast.session.listFailed.title": "Не вдалося завантажити сесії для {{project}}",
|
||||
"toast.project.reloadFailed.title": "Не вдалося перезавантажити {{project}}",
|
||||
|
||||
"toast.update.title": "Доступне оновлення",
|
||||
"toast.update.description": "Нова версія OpenCode ({{version}}) тепер доступна для встановлення.",
|
||||
"toast.update.action.installRestart": "Встановити та перезапустити",
|
||||
"toast.update.action.notYet": "Не зараз",
|
||||
|
||||
"error.page.title": "Щось пішло не так",
|
||||
"error.page.description": "Під час завантаження програми сталася помилка.",
|
||||
"error.page.details.label": "Деталі помилки",
|
||||
"error.page.action.restart": "Перезапустити",
|
||||
"error.page.action.report": "Повідомити про помилку",
|
||||
"error.page.action.reported": "Помилку повідомлено",
|
||||
"error.page.action.checking": "Перевірка...",
|
||||
"error.page.action.checkUpdates": "Перевірити оновлення",
|
||||
"error.page.action.updateTo": "Оновити до {{version}}",
|
||||
"error.page.circular": "[Циклічне]",
|
||||
"error.page.report.prefix": "Будь ласка, повідомте про цю помилку команді OpenCode",
|
||||
"error.page.report.discord": "на Discord",
|
||||
"error.page.version": "Версія: {{version}}",
|
||||
|
||||
"error.dev.rootNotFound":
|
||||
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
|
||||
|
||||
"error.globalSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
|
||||
"error.globalSDK.noServerAvailable": "Сервер недоступний",
|
||||
"error.globalSDK.serverNotAvailable": "Сервер недоступний",
|
||||
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
|
||||
"error.childStore.storeCreateFailed": "Не вдалося створити сховище",
|
||||
"directory.error.invalidUrl": "Недійсний каталог у URL.",
|
||||
|
||||
"error.chain.unknown": "Невідома помилка",
|
||||
"error.server.invalidConfiguration": "Недійсна конфігурація",
|
||||
"error.chain.causedBy": "Причина:",
|
||||
"error.chain.apiError": "Помилка API",
|
||||
"error.chain.status": "Статус: {{status}}",
|
||||
"error.chain.retryable": "Повторювано: {{retryable}}",
|
||||
"error.chain.responseBody": "Тіло відповіді:\n{{body}}",
|
||||
"error.chain.didYouMean": "Можливо, ви мали на увазі: {{suggestions}}",
|
||||
"error.chain.modelNotFound": "Модель не знайдено: {{provider}}/{{model}}",
|
||||
"error.chain.checkConfig": "Перевірте назви провайдерів/моделей у конфігурації (opencode.json)",
|
||||
"error.chain.mcpFailed":
|
||||
'Сервер MCP "{{name}}" не працює. Зверніть увагу, OpenCode ще не підтримує автентифікацію MCP.',
|
||||
"error.chain.providerAuthFailed": "Автентифікація провайдера не вдалася ({{provider}}): {{message}}",
|
||||
"error.chain.providerInitFailed":
|
||||
'Не вдалося ініціалізувати провайдера "{{provider}}". Перевірте облікові дані та конфігурацію.',
|
||||
"error.chain.configJsonInvalid": "Файл конфігурації {{path}} не є дійсним JSON(C)",
|
||||
"error.chain.configJsonInvalidWithMessage": "Файл конфігурації {{path}} не є дійсним JSON(C): {{message}}",
|
||||
"error.chain.configDirectoryTypo":
|
||||
'Каталог "{{dir}}" у {{path}} недійсний. Перейменуйте каталог на "{{suggestion}}" або видаліть його. Це поширена помилка.',
|
||||
"error.chain.configFrontmatterError": "Не вдалося розібрати frontmatter у {{path}}:\n{{message}}",
|
||||
"error.chain.configInvalid": "Файл конфігурації {{path}} недійсний",
|
||||
"error.chain.configInvalidWithMessage": "Файл конфігурації {{path}} недійсний: {{message}}",
|
||||
|
||||
"notification.permission.title": "Потрібен дозвіл",
|
||||
"notification.permission.description": "{{sessionTitle}} у {{projectName}} потребує дозволу",
|
||||
"notification.question.title": "Запитання",
|
||||
"notification.question.description": "{{sessionTitle}} у {{projectName}} має запитання",
|
||||
"notification.action.goToSession": "Перейти до сесії",
|
||||
|
||||
"notification.session.responseReady.title": "Відповідь готова",
|
||||
"notification.session.error.title": "Помилка сесії",
|
||||
"notification.session.error.fallbackDescription": "Сталася помилка",
|
||||
|
||||
"home.recentProjects": "Нещодавні проєкти",
|
||||
"home.empty.title": "Немає нещодавніх проєктів",
|
||||
"home.empty.description": "Почніть, відкривши локальний проєкт",
|
||||
|
||||
"session.tab.session": "Сесія",
|
||||
"session.tab.review": "Огляд",
|
||||
"session.tab.context": "Контекст",
|
||||
"session.panel.reviewAndFiles": "Огляд і файли",
|
||||
"session.review.filesChanged": "Змінено файлів: {{count}}",
|
||||
"session.review.change.one": "Зміна",
|
||||
"session.review.change.other": "Зміни",
|
||||
"session.review.loadingChanges": "Завантаження змін...",
|
||||
"session.review.empty": "У цій сесії ще немає змін",
|
||||
"session.review.noVcs": "Систему контролю версій Git не виявлено, зміни не відображаються",
|
||||
"session.review.noVcs.createGit.title": "Створити Git-репозиторій",
|
||||
"session.review.noVcs.createGit.description": "Відстежуйте, переглядайте та скасовуйте зміни в цьому проєкті",
|
||||
"session.review.noVcs.createGit.actionLoading": "Створення Git-репозиторію...",
|
||||
"session.review.noVcs.createGit.action": "Створити Git-репозиторій",
|
||||
"session.review.noSnapshot": "Відстеження знімків вимкнено в конфігурації, тому зміни сесії недоступні",
|
||||
"session.review.noChanges": "Немає змін",
|
||||
"session.review.noUncommittedChanges": "Ще немає незафіксованих змін",
|
||||
"session.review.noBranchChanges": "Ще немає змін у гілці",
|
||||
|
||||
"session.files.selectToOpen": "Виберіть файл для відкриття",
|
||||
"session.files.all": "Усі файли",
|
||||
"session.files.empty": "Немає файлів",
|
||||
"session.files.binaryContent": "Бінарний файл (вміст не може бути відображено)",
|
||||
|
||||
"session.messages.renderEarlier": "Відобразити раніші повідомлення",
|
||||
"session.messages.loadingEarlier": "Завантаження раніших повідомлень...",
|
||||
"session.messages.loadEarlier": "Завантажити раніші повідомлення",
|
||||
"session.messages.loading": "Завантаження повідомлень...",
|
||||
"session.messages.jumpToLatest": "Перейти до останніх",
|
||||
|
||||
"session.context.addToContext": "Додати {{selection}} до контексту",
|
||||
"session.todo.title": "Завдання",
|
||||
"session.todo.collapse": "Згорнути",
|
||||
"session.todo.expand": "Розгорнути",
|
||||
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
|
||||
"session.question.progress": "{{current}} з {{total}} запитань",
|
||||
"session.followupDock.summary.one": "{{count}} повідомлення в черзі",
|
||||
"session.followupDock.summary.other": "{{count}} повідомлень у черзі",
|
||||
"session.followupDock.sendNow": "Надіслати зараз",
|
||||
"session.followupDock.edit": "Редагувати",
|
||||
"session.followupDock.collapse": "Згорнути повідомлення в черзі",
|
||||
"session.followupDock.expand": "Розгорнути повідомлення в черзі",
|
||||
"session.revertDock.summary.one": "{{count}} скасоване повідомлення",
|
||||
"session.revertDock.summary.other": "{{count}} скасованих повідомлень",
|
||||
"session.revertDock.collapse": "Згорнути скасовані повідомлення",
|
||||
"session.revertDock.expand": "Розгорнути скасовані повідомлення",
|
||||
"session.revertDock.restore": "Відновити повідомлення",
|
||||
|
||||
"session.new.title": "Створити що завгодно",
|
||||
"session.new.worktree.main": "Основна гілка",
|
||||
"session.new.worktree.mainWithBranch": "Основна гілка ({{branch}})",
|
||||
"session.new.worktree.create": "Створити нове робоче дерево",
|
||||
"session.new.lastModified": "Востаннє змінено",
|
||||
|
||||
"session.header.search.placeholder": "Пошук {{project}}",
|
||||
"session.header.searchFiles": "Пошук файлів",
|
||||
"session.header.openIn": "Відкрити в",
|
||||
"session.header.open.action": "Відкрити {{app}}",
|
||||
"session.header.open.ariaLabel": "Відкрити в {{app}}",
|
||||
"session.header.open.menu": "Параметри відкриття",
|
||||
"session.header.open.copyPath": "Копіювати шлях",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Провідник файлів",
|
||||
"session.header.open.fileManager": "Файловий менеджер",
|
||||
"session.header.open.app.vscode": "VS Code",
|
||||
"session.header.open.app.cursor": "Cursor",
|
||||
"session.header.open.app.zed": "Zed",
|
||||
"session.header.open.app.textmate": "TextMate",
|
||||
"session.header.open.app.antigravity": "Antigravity",
|
||||
"session.header.open.app.terminal": "Термінал",
|
||||
"session.header.open.app.iterm2": "iTerm2",
|
||||
"session.header.open.app.ghostty": "Ghostty",
|
||||
"session.header.open.app.warp": "Warp",
|
||||
"session.header.open.app.xcode": "Xcode",
|
||||
"session.header.open.app.androidStudio": "Android Studio",
|
||||
"session.header.open.app.powershell": "PowerShell",
|
||||
"session.header.open.app.sublimeText": "Sublime Text",
|
||||
|
||||
"status.popover.trigger": "Статус",
|
||||
"status.popover.ariaLabel": "Конфігурації серверів",
|
||||
"status.popover.tab.servers": "Сервери",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Плагіни",
|
||||
"status.popover.action.manageServers": "Керувати серверами",
|
||||
|
||||
"session.share.popover.title": "Опублікувати в інтернеті",
|
||||
"session.share.popover.description.shared":
|
||||
"Ця сесія є публічною в інтернеті. Вона доступна будь-кому за посиланням.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Опублікуйте сесію публічно в інтернеті. Вона буде доступна будь-кому за посиланням.",
|
||||
"session.share.action.share": "Поділитися",
|
||||
"session.share.action.publish": "Опублікувати",
|
||||
"session.share.action.publishing": "Публікація...",
|
||||
"session.share.action.unpublish": "Скасувати публікацію",
|
||||
"session.share.action.unpublishing": "Скасування публікації...",
|
||||
"session.share.action.view": "Переглянути",
|
||||
"session.share.copy.copied": "Скопійовано",
|
||||
"session.share.copy.copyLink": "Копіювати посилання",
|
||||
|
||||
"lsp.tooltip.none": "Немає серверів LSP",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
|
||||
"prompt.loading": "Завантаження запиту...",
|
||||
"terminal.loading": "Завантаження термінала...",
|
||||
"terminal.title": "Термінал",
|
||||
"terminal.title.numbered": "Термінал {{number}}",
|
||||
"terminal.close": "Закрити термінал",
|
||||
"terminal.connectionLost.title": "З'єднання втрачено",
|
||||
"terminal.connectionLost.abnormalClose": "WebSocket закрито аномально: {{code}}",
|
||||
"terminal.connectionLost.description":
|
||||
"З'єднання з терміналом було перервано. Це може статися під час перезапуску сервера.",
|
||||
|
||||
"common.closeTab": "Закрити вкладку",
|
||||
"common.dismiss": "Відхилити",
|
||||
"common.moreCountSuffix": " (ще {{count}})",
|
||||
"common.requestFailed": "Запит не виконано",
|
||||
"common.moreOptions": "Більше опцій",
|
||||
"common.learnMore": "Дізнатися більше",
|
||||
"common.rename": "Перейменувати",
|
||||
"common.reset": "Скинути",
|
||||
"common.archive": "Архівувати",
|
||||
"common.delete": "Видалити",
|
||||
"common.close": "Закрити",
|
||||
"common.edit": "Редагувати",
|
||||
"common.loadMore": "Завантажити більше",
|
||||
"common.key.esc": "ESC",
|
||||
"common.key.ctrl": "Ctrl",
|
||||
"common.key.alt": "Alt",
|
||||
"common.key.shift": "Shift",
|
||||
"common.key.meta": "Meta",
|
||||
"common.key.space": "Пробіл",
|
||||
"common.key.backspace": "Backspace",
|
||||
"common.key.enter": "Enter",
|
||||
"common.key.tab": "Tab",
|
||||
"common.key.delete": "Delete",
|
||||
"common.key.home": "Home",
|
||||
"common.key.end": "End",
|
||||
"common.key.pageUp": "Page Up",
|
||||
"common.key.pageDown": "Page Down",
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "невідомо",
|
||||
|
||||
"common.time.justNow": "Щойно",
|
||||
"common.time.minutesAgo.short": "{{count}} хв тому",
|
||||
"common.time.hoursAgo.short": "{{count}} год тому",
|
||||
"common.time.daysAgo.short": "{{count}} дн тому",
|
||||
|
||||
"sidebar.menu.toggle": "Перемкнути меню",
|
||||
"sidebar.nav.projectsAndSessions": "Проєкти та сесії",
|
||||
"sidebar.settings": "Налаштування",
|
||||
"sidebar.help": "Довідка",
|
||||
"sidebar.workspaces.enable": "Увімкнути робочі області",
|
||||
"sidebar.workspaces.disable": "Вимкнути робочі області",
|
||||
"sidebar.gettingStarted.title": "Початок роботи",
|
||||
"sidebar.gettingStarted.line1": "OpenCode містить безкоштовні моделі, тому ви можете почати негайно.",
|
||||
"sidebar.gettingStarted.line2":
|
||||
"Підключіть будь-якого провайдера, щоб використовувати моделі, включаючи Claude, GPT, Gemini тощо.",
|
||||
"sidebar.project.recentSessions": "Нещодавні сесії",
|
||||
"sidebar.project.viewAllSessions": "Переглянути всі сесії",
|
||||
"sidebar.project.clearNotifications": "Очистити сповіщення",
|
||||
"sidebar.empty.title": "Немає відкритих проєктів",
|
||||
"sidebar.empty.description": "Відкрийте проєкт, щоб почати",
|
||||
|
||||
"debugBar.ariaLabel": "Діагностика продуктивності розробки",
|
||||
"debugBar.na": "н/д",
|
||||
"debugBar.nav.label": "NAV",
|
||||
"debugBar.nav.tip":
|
||||
"Останній завершений перехід маршруту, що торкається сторінки сесії, виміряний від запуску маршрутизатора до першого відображення після стабілізації.",
|
||||
"debugBar.fps.label": "FPS",
|
||||
"debugBar.fps.tip": "Поточна кількість кадрів за секунду за останні 5 секунд.",
|
||||
"debugBar.frame.label": "FRAME",
|
||||
"debugBar.frame.tip": "Найгірший час кадру за останні 5 секунд.",
|
||||
"debugBar.jank.label": "JANK",
|
||||
"debugBar.jank.tip": "Кадри понад 32 мс за останні 5 секунд.",
|
||||
"debugBar.long.label": "LONG",
|
||||
"debugBar.long.tip": "Заблокований час і кількість довгих завдань за останні 5 секунд. Макс. завдання: {{max}}.",
|
||||
"debugBar.delay.label": "DELAY",
|
||||
"debugBar.delay.tip": "Найгірша спостережувана затримка введення за останні 5 секунд.",
|
||||
"debugBar.inp.label": "INP",
|
||||
"debugBar.inp.tip":
|
||||
"Приблизна тривалість взаємодії за останні 5 секунд. Це схоже на INP, а не на офіційний Web Vitals INP.",
|
||||
"debugBar.cls.label": "CLS",
|
||||
"debugBar.cls.tip": "Сукупний зсув макета за весь час роботи програми.",
|
||||
"debugBar.mem.label": "MEM",
|
||||
"debugBar.mem.tipUnavailable": "Використана купа JS проти ліміту купи. Тільки Chromium.",
|
||||
"debugBar.mem.tip": "Використана купа JS проти ліміту купи. {{used}} з {{limit}}.",
|
||||
|
||||
"app.name.desktop": "OpenCode Desktop",
|
||||
|
||||
"settings.section.desktop": "Робочий стіл",
|
||||
"settings.section.server": "Сервер",
|
||||
"settings.tab.general": "Загальні",
|
||||
"settings.tab.shortcuts": "Скорочення",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
"settings.desktop.wsl.title": "Інтеграція WSL",
|
||||
"settings.desktop.wsl.description": "Запускати сервер OpenCode всередині WSL на Windows.",
|
||||
|
||||
"settings.general.section.appearance": "Зовнішній вигляд",
|
||||
"settings.general.section.advanced": "Додатково",
|
||||
"settings.general.section.notifications": "Системні сповіщення",
|
||||
"settings.general.section.updates": "Оновлення",
|
||||
"settings.general.section.sounds": "Звукові ефекти",
|
||||
"settings.general.section.feed": "Стрічка",
|
||||
"settings.general.section.display": "Дисплей",
|
||||
|
||||
"settings.general.row.language.title": "Мова",
|
||||
"settings.general.row.language.description": "Змінити мову інтерфейсу OpenCode",
|
||||
"settings.general.row.shell.title": "Командна оболонка термінала",
|
||||
"settings.general.row.shell.description":
|
||||
"Виберіть оболонку для термінала. Сумісні оболонки також використовуються для викликів інструментів агента.",
|
||||
"settings.general.row.shell.autoDefault": "Авто (за замовчуванням)",
|
||||
"settings.general.row.shell.terminalOnly": "тільки термінал",
|
||||
"settings.general.row.appearance.title": "Зовнішній вигляд",
|
||||
"settings.general.row.appearance.description": "Налаштуйте вигляд OpenCode на вашому пристрої",
|
||||
"settings.general.row.colorScheme.title": "Кольорова схема",
|
||||
"settings.general.row.colorScheme.description": "Виберіть, чи OpenCode використовує системну, світлу або темну тему",
|
||||
"settings.general.row.theme.title": "Тема",
|
||||
"settings.general.row.theme.description": "Налаштуйте тему OpenCode.",
|
||||
"settings.general.row.font.title": "Шрифт коду",
|
||||
"settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
|
||||
"settings.general.row.terminalFont.title": "Шрифт термінала",
|
||||
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі",
|
||||
"settings.general.row.uiFont.title": "Шрифт інтерфейсу",
|
||||
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
|
||||
"settings.general.row.followup.title": "Поведінка продовження",
|
||||
"settings.general.row.followup.description": "Виберіть, чи продовження виконується негайно, чи чекає в черзі",
|
||||
"settings.general.row.followup.option.queue": "Черга",
|
||||
"settings.general.row.followup.option.steer": "Керування",
|
||||
"settings.general.row.showFileTree.title": "Дерево файлів",
|
||||
"settings.general.row.showFileTree.description":
|
||||
"Показувати перемикач і панель дерева файлів у сесіях на робочому столі",
|
||||
"settings.general.row.showNavigation.title": "Елементи навігації",
|
||||
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
|
||||
"settings.general.row.showSearch.title": "Палітра команд",
|
||||
"settings.general.row.showSearch.description":
|
||||
"Показувати кнопку пошуку та палітри команд у заголовку робочого столу",
|
||||
"settings.general.row.showTerminal.title": "Термінал",
|
||||
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
|
||||
"settings.general.row.showStatus.title": "Статус сервера",
|
||||
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку робочого столу",
|
||||
"settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення",
|
||||
"settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Показувати частини інструменту оболонки розгорнутими за замовчуванням на часовій шкалі",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
|
||||
"settings.general.row.showSessionProgressBar.title": "Показувати індикатор прогресу сесії",
|
||||
"settings.general.row.showSessionProgressBar.description":
|
||||
"Відображати анімований індикатор прогресу вгорі сесії, коли агент працює",
|
||||
|
||||
"settings.general.row.wayland.title": "Використовувати нативний Wayland",
|
||||
"settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
"На Linux з моніторами з різною частотою оновлення нативний Wayland може бути більш стабільним.",
|
||||
|
||||
"settings.general.row.releaseNotes.title": "Нотатки до релізу",
|
||||
"settings.general.row.releaseNotes.description": 'Показувати спливаючі вікна "Що нового" після оновлень',
|
||||
|
||||
"settings.updates.row.startup.title": "Перевіряти оновлення під час запуску",
|
||||
"settings.updates.row.startup.description": "Автоматично перевіряти наявність оновлень під час запуску OpenCode",
|
||||
"settings.updates.row.check.title": "Перевірити оновлення",
|
||||
"settings.updates.row.check.description": "Вручну перевірити наявність оновлень і встановити, якщо доступні",
|
||||
"settings.updates.action.checkNow": "Перевірити зараз",
|
||||
"settings.updates.action.checking": "Перевірка...",
|
||||
"settings.updates.toast.latest.title": "У вас актуальна версія",
|
||||
"settings.updates.toast.latest.description": "Ви використовуєте останню версію OpenCode.",
|
||||
"sound.option.none": "Немає",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
"sound.option.alert03": "Alert 03",
|
||||
"sound.option.alert04": "Alert 04",
|
||||
"sound.option.alert05": "Alert 05",
|
||||
"sound.option.alert06": "Alert 06",
|
||||
"sound.option.alert07": "Alert 07",
|
||||
"sound.option.alert08": "Alert 08",
|
||||
"sound.option.alert09": "Alert 09",
|
||||
"sound.option.alert10": "Alert 10",
|
||||
"sound.option.bipbop01": "Bip-bop 01",
|
||||
"sound.option.bipbop02": "Bip-bop 02",
|
||||
"sound.option.bipbop03": "Bip-bop 03",
|
||||
"sound.option.bipbop04": "Bip-bop 04",
|
||||
"sound.option.bipbop05": "Bip-bop 05",
|
||||
"sound.option.bipbop06": "Bip-bop 06",
|
||||
"sound.option.bipbop07": "Bip-bop 07",
|
||||
"sound.option.bipbop08": "Bip-bop 08",
|
||||
"sound.option.bipbop09": "Bip-bop 09",
|
||||
"sound.option.bipbop10": "Bip-bop 10",
|
||||
"sound.option.staplebops01": "Staplebops 01",
|
||||
"sound.option.staplebops02": "Staplebops 02",
|
||||
"sound.option.staplebops03": "Staplebops 03",
|
||||
"sound.option.staplebops04": "Staplebops 04",
|
||||
"sound.option.staplebops05": "Staplebops 05",
|
||||
"sound.option.staplebops06": "Staplebops 06",
|
||||
"sound.option.staplebops07": "Staplebops 07",
|
||||
"sound.option.nope01": "Nope 01",
|
||||
"sound.option.nope02": "Nope 02",
|
||||
"sound.option.nope03": "Nope 03",
|
||||
"sound.option.nope04": "Nope 04",
|
||||
"sound.option.nope05": "Nope 05",
|
||||
"sound.option.nope06": "Nope 06",
|
||||
"sound.option.nope07": "Nope 07",
|
||||
"sound.option.nope08": "Nope 08",
|
||||
"sound.option.nope09": "Nope 09",
|
||||
"sound.option.nope10": "Nope 10",
|
||||
"sound.option.nope11": "Nope 11",
|
||||
"sound.option.nope12": "Nope 12",
|
||||
"sound.option.yup01": "Yup 01",
|
||||
"sound.option.yup02": "Yup 02",
|
||||
"sound.option.yup03": "Yup 03",
|
||||
"sound.option.yup04": "Yup 04",
|
||||
"sound.option.yup05": "Yup 05",
|
||||
"sound.option.yup06": "Yup 06",
|
||||
|
||||
"settings.general.notifications.agent.title": "Агент",
|
||||
"settings.general.notifications.agent.description":
|
||||
"Показувати системне сповіщення, коли агент завершує роботу або потребує уваги",
|
||||
"settings.general.notifications.permissions.title": "Дозволи",
|
||||
"settings.general.notifications.permissions.description": "Показувати системне сповіщення, коли потрібен дозвіл",
|
||||
"settings.general.notifications.errors.title": "Помилки",
|
||||
"settings.general.notifications.errors.description": "Показувати системне сповіщення, коли виникає помилка",
|
||||
|
||||
"settings.general.sounds.agent.title": "Агент",
|
||||
"settings.general.sounds.agent.description": "Відтворювати звук, коли агент завершує роботу або потребує уваги",
|
||||
"settings.general.sounds.permissions.title": "Дозволи",
|
||||
"settings.general.sounds.permissions.description": "Відтворювати звук, коли потрібен дозвіл",
|
||||
"settings.general.sounds.errors.title": "Помилки",
|
||||
"settings.general.sounds.errors.description": "Відтворювати звук, коли виникає помилка",
|
||||
|
||||
"settings.shortcuts.title": "Скорочення клавіш",
|
||||
"settings.shortcuts.reset.button": "Скинути до стандартних",
|
||||
"settings.shortcuts.reset.toast.title": "Скорочення скинуто",
|
||||
"settings.shortcuts.reset.toast.description": "Скорочення клавіш були скинуті до стандартних.",
|
||||
"settings.shortcuts.conflict.title": "Скорочення вже використовується",
|
||||
"settings.shortcuts.conflict.description": "{{keybind}} вже призначено для {{titles}}.",
|
||||
"settings.shortcuts.unassigned": "Не призначено",
|
||||
"settings.shortcuts.pressKeys": "Натисніть клавіші",
|
||||
"settings.shortcuts.search.placeholder": "Пошук скорочень",
|
||||
"settings.shortcuts.search.empty": "Скорочень не знайдено",
|
||||
|
||||
"settings.shortcuts.group.general": "Загальні",
|
||||
"settings.shortcuts.group.session": "Сесія",
|
||||
"settings.shortcuts.group.navigation": "Навігація",
|
||||
"settings.shortcuts.group.modelAndAgent": "Модель та агент",
|
||||
"settings.shortcuts.group.terminal": "Термінал",
|
||||
"settings.shortcuts.group.prompt": "Запит",
|
||||
|
||||
"settings.providers.title": "Провайдери",
|
||||
"settings.providers.description": "Налаштування провайдерів будуть доступні тут.",
|
||||
"settings.providers.section.connected": "Підключені провайдери",
|
||||
"settings.providers.connected.empty": "Немає підключених провайдерів",
|
||||
"settings.providers.connected.environmentDescription": "Підключено зі змінних середовища",
|
||||
"settings.providers.section.popular": "Популярні провайдери",
|
||||
"settings.providers.custom.description": "Додайте провайдера, сумісного з OpenAI, за базовим URL.",
|
||||
"settings.providers.tag.environment": "Середовище",
|
||||
"settings.providers.tag.config": "Конфігурація",
|
||||
"settings.providers.tag.custom": "Користувацький",
|
||||
"settings.providers.tag.other": "Інше",
|
||||
"settings.models.title": "Моделі",
|
||||
"settings.models.description": "Налаштування моделей будуть доступні тут.",
|
||||
"settings.agents.title": "Агенти",
|
||||
"settings.agents.description": "Налаштування агентів будуть доступні тут.",
|
||||
"settings.commands.title": "Команди",
|
||||
"settings.commands.description": "Налаштування команд будуть доступні тут.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "Налаштування MCP будуть доступні тут.",
|
||||
|
||||
"settings.permissions.title": "Дозволи",
|
||||
"settings.permissions.description": "Керуйте тим, які інструменти сервер може використовувати за замовчуванням.",
|
||||
"settings.permissions.section.tools": "Інструменти",
|
||||
"settings.permissions.toast.updateFailed.title": "Не вдалося оновити дозволи",
|
||||
|
||||
"settings.permissions.action.allow": "Дозволити",
|
||||
"settings.permissions.action.ask": "Запитувати",
|
||||
"settings.permissions.action.deny": "Заборонити",
|
||||
|
||||
"settings.permissions.tool.read.title": "Читання",
|
||||
"settings.permissions.tool.read.description": "Читання файлу (відповідає шляху файлу)",
|
||||
"settings.permissions.tool.edit.title": "Редагування",
|
||||
"settings.permissions.tool.edit.description": "Зміна файлів, включаючи редагування, запис і патчі",
|
||||
"settings.permissions.tool.glob.title": "Glob",
|
||||
"settings.permissions.tool.glob.description": "Зіставлення файлів за допомогою glob-шаблонів",
|
||||
"settings.permissions.tool.grep.title": "Grep",
|
||||
"settings.permissions.tool.grep.description": "Пошук вмісту файлів за допомогою регулярних виразів",
|
||||
"settings.permissions.tool.list.title": "Список",
|
||||
"settings.permissions.tool.list.description": "Список файлів у каталозі",
|
||||
"settings.permissions.tool.bash.title": "Bash",
|
||||
"settings.permissions.tool.bash.description": "Запуск команд оболонки",
|
||||
"settings.permissions.tool.task.title": "Завдання",
|
||||
"settings.permissions.tool.task.description": "Запуск підагентів",
|
||||
"settings.permissions.tool.skill.title": "Навичка",
|
||||
"settings.permissions.tool.skill.description": "Завантаження навички за назвою",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Оновлення списку завдань",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Отримання вмісту з URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
"settings.permissions.tool.websearch.description": "Пошук в інтернеті",
|
||||
"settings.permissions.tool.external_directory.title": "Зовнішній каталог",
|
||||
"settings.permissions.tool.external_directory.description": "Доступ до файлів за межами каталогу проєкту",
|
||||
"settings.permissions.tool.doom_loop.title": "Цикл приреченості",
|
||||
"settings.permissions.tool.doom_loop.description":
|
||||
"Виявлення повторюваних викликів інструментів з однаковими вхідними даними",
|
||||
|
||||
"session.delete.failed.title": "Не вдалося видалити сесію",
|
||||
"session.delete.title": "Видалити сесію",
|
||||
"session.delete.confirm": 'Видалити сесію "{{name}}"?',
|
||||
"session.delete.button": "Видалити сесію",
|
||||
|
||||
"workspace.new": "Нова робоча область",
|
||||
"workspace.type.local": "локальна",
|
||||
"workspace.type.sandbox": "пісочниця",
|
||||
"workspace.create.failed.title": "Не вдалося створити робочу область",
|
||||
"workspace.delete.failed.title": "Не вдалося видалити робочу область",
|
||||
"workspace.resetting.title": "Скидання робочої області",
|
||||
"workspace.resetting.description": "Це може зайняти хвилину.",
|
||||
"workspace.reset.failed.title": "Не вдалося скинути робочу область",
|
||||
"workspace.reset.success.title": "Робочу область скинуто",
|
||||
"workspace.reset.success.description": "Робоча область тепер відповідає гілці за замовчуванням.",
|
||||
"workspace.error.stillPreparing": "Робоча область все ще готується",
|
||||
"workspace.status.checking": "Перевірка незлитих змін...",
|
||||
"workspace.status.error": "Не вдалося перевірити статус git.",
|
||||
"workspace.status.clean": "Незлитих змін не виявлено.",
|
||||
"workspace.status.dirty": "Виявлено незлиті зміни в цій робочій області.",
|
||||
"workspace.delete.title": "Видалити робочу область",
|
||||
"workspace.delete.confirm": 'Видалити робочу область "{{name}}"?',
|
||||
"workspace.delete.button": "Видалити робочу область",
|
||||
"workspace.reset.title": "Скинути робочу область",
|
||||
"workspace.reset.confirm": 'Скинути робочу область "{{name}}"?',
|
||||
"workspace.reset.button": "Скинути робочу область",
|
||||
"workspace.reset.archived.none": "Жодна активна сесія не буде заархівована.",
|
||||
"workspace.reset.archived.one": "1 сесію буде заархівовано.",
|
||||
"workspace.reset.archived.many": "{{count}} сесій буде заархівовано.",
|
||||
"workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.",
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
@@ -53,6 +54,63 @@
|
||||
container-name: getting-started;
|
||||
}
|
||||
|
||||
[data-component="dropdown-menu-content"].desktop-app-menu,
|
||||
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
|
||||
min-width: 160px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
[data-component="dropdown-menu-content"].desktop-app-menu {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
|
||||
width: max-content;
|
||||
min-width: 240px;
|
||||
max-width: min(320px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: var(--font-size-x-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
|
||||
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"],
|
||||
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
|
||||
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] {
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"],
|
||||
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="desktop-app-menu-keybind"] {
|
||||
margin-left: auto;
|
||||
color: var(--text-weak);
|
||||
font-size: var(--font-size-x-small);
|
||||
font-weight: var(--font-weight-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="desktop-app-menu-chevron"] {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
[data-component="getting-started-actions"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const location = useLocation()
|
||||
@@ -40,6 +41,15 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
)
|
||||
}
|
||||
|
||||
export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString"))
|
||||
export type ProjectDirString = Schema.Schema.Type<typeof ProjectDirString>
|
||||
|
||||
export function decodeDirectory(dir: string): ProjectDirString | undefined {
|
||||
const decoded = decode64(dir)
|
||||
if (!decoded) return
|
||||
return ProjectDirString.make(decoded)
|
||||
}
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
@@ -48,7 +58,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const resolved = createMemo(() => {
|
||||
if (!params.dir) return ""
|
||||
return decode64(params.dir) ?? ""
|
||||
return decodeDirectory(params.dir) ?? ""
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
@@ -64,6 +64,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { same } from "@/utils/same"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
@@ -75,7 +76,6 @@ type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
loaded: () => number
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
@@ -85,205 +85,74 @@ type SessionHistoryWindowInput = {
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains the rendered history window for a session timeline.
|
||||
*
|
||||
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
||||
* small batches while scrolling upward, and prefetches older history near top.
|
||||
*/
|
||||
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
const turnInit = 10
|
||||
const turnBatch = 8
|
||||
const turnScrollThreshold = 200
|
||||
const turnPrefetchBuffer = 16
|
||||
const prefetchCooldownMs = 400
|
||||
const prefetchNoGrowthLimit = 2
|
||||
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
||||
const historyScrollThreshold = 200
|
||||
let shiftFrame: number | undefined
|
||||
|
||||
const [state, setState] = createStore({
|
||||
turnID: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
prefetchUntil: 0,
|
||||
prefetchNoGrowth: 0,
|
||||
shift: false,
|
||||
})
|
||||
|
||||
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
||||
|
||||
const turnStart = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
const len = input.visibleUserMessages().length
|
||||
if (!id || len <= 0) return 0
|
||||
if (state.turnID !== id) return initialTurnStart(len)
|
||||
if (state.turnStart <= 0) return 0
|
||||
if (state.turnStart >= len) return initialTurnStart(len)
|
||||
return state.turnStart
|
||||
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
|
||||
equals: same,
|
||||
})
|
||||
|
||||
const setTurnStart = (start: number) => {
|
||||
const id = input.sessionID()
|
||||
const next = start > 0 ? start : 0
|
||||
if (!id) {
|
||||
setState({ turnID: undefined, turnStart: next })
|
||||
return
|
||||
}
|
||||
setState({ turnID: id, turnStart: next })
|
||||
const cancelShiftReset = () => {
|
||||
if (shiftFrame === undefined) return
|
||||
cancelAnimationFrame(shiftFrame)
|
||||
shiftFrame = undefined
|
||||
}
|
||||
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = input.visibleUserMessages()
|
||||
const start = turnStart()
|
||||
if (start <= 0) return msgs
|
||||
return msgs.slice(start)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
|
||||
const preserveScroll = (fn: () => void) => {
|
||||
const el = input.scroller()
|
||||
if (!el) {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
fn()
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
const scheduleShiftReset = () => {
|
||||
cancelShiftReset()
|
||||
shiftFrame = requestAnimationFrame(() => {
|
||||
shiftFrame = undefined
|
||||
setState("shift", false)
|
||||
})
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
const start = turnStart()
|
||||
if (start <= 0) return
|
||||
|
||||
const next = start - turnBatch
|
||||
const nextStart = next > 0 ? next : 0
|
||||
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
|
||||
const loadAndReveal = async () => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
|
||||
const start = turnStart()
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
let loaded = input.loaded()
|
||||
|
||||
if (start > 0) setTurnStart(0)
|
||||
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
let afterVisible = beforeVisible
|
||||
let added = 0
|
||||
|
||||
while (true) {
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
afterVisible = input.visibleUserMessages().length
|
||||
const nextLoaded = input.loaded()
|
||||
const raw = nextLoaded - loaded
|
||||
added += raw
|
||||
loaded = nextLoaded
|
||||
|
||||
if (afterVisible > beforeVisible) break
|
||||
if (raw <= 0) break
|
||||
if (!input.historyMore()) break
|
||||
}
|
||||
|
||||
if (added <= 0) return
|
||||
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
||||
|
||||
const growth = afterVisible - beforeVisible
|
||||
if (growth <= 0) return
|
||||
if (turnStart() !== 0) return
|
||||
|
||||
const target = Math.min(afterVisible, beforeVisible + turnBatch)
|
||||
setTurnStart(Math.max(0, afterVisible - target))
|
||||
}
|
||||
|
||||
/** Scroll/prefetch path: fetch older history from server. */
|
||||
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
||||
const fetchOlderMessages = async () => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const now = Date.now()
|
||||
if (state.prefetchUntil > now) return
|
||||
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
||||
setState("prefetchUntil", now + prefetchCooldownMs)
|
||||
}
|
||||
|
||||
const start = turnStart()
|
||||
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
||||
let loaded = input.loaded()
|
||||
let added = 0
|
||||
let growth = 0
|
||||
|
||||
cancelShiftReset()
|
||||
setState("shift", true)
|
||||
|
||||
while (true) {
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const nextLoaded = input.loaded()
|
||||
const raw = nextLoaded - loaded
|
||||
added += raw
|
||||
loaded = nextLoaded
|
||||
growth = input.visibleUserMessages().length - beforeVisible
|
||||
|
||||
if (growth > 0) break
|
||||
if (raw <= 0) break
|
||||
if (opts?.prefetch) break
|
||||
if (!input.historyMore()) break
|
||||
}
|
||||
|
||||
const afterVisible = input.visibleUserMessages().length
|
||||
|
||||
if (opts?.prefetch) {
|
||||
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
|
||||
} else if (added > 0 && state.prefetchNoGrowth) {
|
||||
setState("prefetchNoGrowth", 0)
|
||||
}
|
||||
|
||||
if (added <= 0) return
|
||||
if (growth <= 0) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const current = turnStart()
|
||||
preserveScroll(() => setTurnStart(current + growth))
|
||||
if (growth > 0) {
|
||||
scheduleShiftReset()
|
||||
return
|
||||
}
|
||||
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = Math.min(afterVisible, base + turnBatch)
|
||||
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
|
||||
setState("shift", false)
|
||||
}
|
||||
|
||||
const loadAndReveal = () => fetchOlderMessages()
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
if (start <= turnPrefetchBuffer) {
|
||||
void fetchOlderMessages({ prefetch: true })
|
||||
}
|
||||
backfillTurns()
|
||||
return
|
||||
}
|
||||
if (el.scrollTop >= historyScrollThreshold) return
|
||||
|
||||
void fetchOlderMessages()
|
||||
}
|
||||
@@ -292,27 +161,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
on(
|
||||
input.sessionID,
|
||||
() => {
|
||||
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
||||
cancelShiftReset()
|
||||
setState({ shift: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionID(), input.messagesReady()] as const,
|
||||
([id, ready]) => {
|
||||
if (!id || !ready) return
|
||||
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
onCleanup(cancelShiftReset)
|
||||
|
||||
return {
|
||||
turnStart,
|
||||
setTurnStart,
|
||||
renderedUserMessages,
|
||||
userMessages,
|
||||
shift: () => state.shift,
|
||||
loadAndReveal,
|
||||
onScrollerScroll,
|
||||
}
|
||||
@@ -333,6 +193,7 @@ export default function Page() {
|
||||
const comments = useComments()
|
||||
const terminal = useTerminal()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
createEffect(() => {
|
||||
@@ -737,6 +598,7 @@ export default function Page() {
|
||||
let dockHeight = 0
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let revealMessage = (_id: string) => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
|
||||
@@ -1403,9 +1265,8 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
|
||||
const historyWindow = createSessionHistoryWindow({
|
||||
const historyLoader = createSessionHistoryLoader({
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
loaded: () => messages().length,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
@@ -1427,9 +1288,9 @@ export default function Page() {
|
||||
const el = scroller
|
||||
if (!el) return
|
||||
if (el.scrollHeight > el.clientHeight + 1) return
|
||||
if (historyWindow.turnStart() <= 0 && !historyMore()) return
|
||||
if (!historyMore()) return
|
||||
|
||||
void historyWindow.loadAndReveal()
|
||||
void historyLoader.loadAndReveal()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1439,15 +1300,14 @@ export default function Page() {
|
||||
[
|
||||
params.id,
|
||||
messagesReady(),
|
||||
historyWindow.turnStart(),
|
||||
historyMore(),
|
||||
historyLoading(),
|
||||
autoScroll.userScrolled(),
|
||||
visibleUserMessages().length,
|
||||
] as const,
|
||||
([id, ready, start, more, loading, scrolled]) => {
|
||||
([id, ready, more, loading, scrolled]) => {
|
||||
if (!id || !ready || loading || scrolled) return
|
||||
if (start <= 0 && !more) return
|
||||
if (!more) return
|
||||
fill()
|
||||
},
|
||||
{ defer: true },
|
||||
@@ -1749,15 +1609,14 @@ export default function Page() {
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
setTurnStart: historyWindow.setTurnStart,
|
||||
autoScroll,
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
revealMessage: (id) => revealMessage(id),
|
||||
scheduleScrollState,
|
||||
consumePendingMessage: layout.pendingMessage.consume,
|
||||
})
|
||||
@@ -1787,6 +1646,8 @@ export default function Page() {
|
||||
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
||||
})
|
||||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
{sessionSync() ?? ""}
|
||||
@@ -1830,20 +1691,23 @@ export default function Page() {
|
||||
>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
@@ -1853,8 +1717,11 @@ export default function Page() {
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onHistoryScroll={historyLoader.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
@@ -1863,14 +1730,12 @@ export default function Page() {
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
historyShift={historyLoader.shift()}
|
||||
userMessages={historyLoader.userMessages()}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<div data-slot="question-text" class="overflow-auto">
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
export type TimelineRowMap = {
|
||||
CommentStrip: {
|
||||
userMessageID: string
|
||||
previousUserMessage: boolean
|
||||
}
|
||||
UserMessage: {
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
previousUserMessage: boolean
|
||||
}
|
||||
TurnDivider: {
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
}
|
||||
AssistantPart: {
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
previousAssistantPart: boolean
|
||||
lastAssistantPart: boolean
|
||||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
BottomSpacer: {}
|
||||
}
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
|
||||
userMessageID: string
|
||||
previousUserMessage: boolean
|
||||
}> {}
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
previousUserMessage: boolean
|
||||
}> {}
|
||||
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
}> {}
|
||||
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
previousAssistantPart: boolean
|
||||
lastAssistantPart: boolean
|
||||
}> {}
|
||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||
userMessageID: string
|
||||
reasoningHeading?: string
|
||||
}> {}
|
||||
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
|
||||
userMessageID: string
|
||||
diffs: SummaryDiff[]
|
||||
}> {}
|
||||
export class Error extends Data.TaggedClass("Error")<{
|
||||
userMessageID: string
|
||||
text: string
|
||||
}> {}
|
||||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| TurnDivider
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
| BottomSpacer
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
case "CommentStrip":
|
||||
return `comment-strip:${row.userMessageID}`
|
||||
case "UserMessage":
|
||||
return `user-message:${row.userMessageID}`
|
||||
case "TurnDivider":
|
||||
return `turn-divider:${row.userMessageID}:${row.label}`
|
||||
case "AssistantPart":
|
||||
return `assistant-part:${row.userMessageID}:${row.group.key}`
|
||||
case "Thinking":
|
||||
return `thinking:${row.userMessageID}`
|
||||
case "DiffSummary":
|
||||
return `diff-summary:${row.userMessageID}`
|
||||
case "Error":
|
||||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
case "BottomSpacer":
|
||||
return "bottom-spacer"
|
||||
}
|
||||
}
|
||||
|
||||
export function equals(a: TimelineRow, b: TimelineRow) {
|
||||
return Equal.equals(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Timeline {
|
||||
export function constructMessageRows(
|
||||
userMessage: UserMessage,
|
||||
getMessageParts: (messageID: string) => Part[],
|
||||
assistantMessages: AssistantMessage[],
|
||||
index: number,
|
||||
showReasoning: boolean,
|
||||
status: SessionStatus["type"],
|
||||
isActive: boolean,
|
||||
) {
|
||||
const rows: TimelineRow.TimelineRow[] = []
|
||||
|
||||
const previousUserMessage = index > 0
|
||||
const userParts = getMessageParts(userMessage.id)
|
||||
const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
|
||||
const compaction = userParts.some((p) => p.type === "compaction")
|
||||
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
|
||||
const interrupted = interruptedMessageIndex !== -1
|
||||
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
|
||||
|
||||
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
|
||||
getMessageParts(message.id)
|
||||
.filter((part) => renderable(part, showReasoning))
|
||||
.map((part) => ({ messageID: message.id, messageIndex, part })),
|
||||
)
|
||||
const assistantItems =
|
||||
interrupted && !compaction
|
||||
? [
|
||||
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map(
|
||||
(group) => ({
|
||||
type: "part" as const,
|
||||
group,
|
||||
}),
|
||||
),
|
||||
{ type: "interrupted" as const },
|
||||
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map(
|
||||
(group) => ({
|
||||
type: "part" as const,
|
||||
group,
|
||||
}),
|
||||
),
|
||||
]
|
||||
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
||||
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
|
||||
|
||||
if (comments.length > 0)
|
||||
rows.push(
|
||||
new TimelineRow.CommentStrip({
|
||||
userMessageID: userMessage.id,
|
||||
previousUserMessage,
|
||||
}),
|
||||
)
|
||||
|
||||
rows.push(
|
||||
new TimelineRow.UserMessage({
|
||||
userMessageID: userMessage.id,
|
||||
anchor: comments.length === 0,
|
||||
previousUserMessage: comments.length === 0 && previousUserMessage,
|
||||
}),
|
||||
)
|
||||
|
||||
if (compaction) {
|
||||
rows.push(
|
||||
new TimelineRow.TurnDivider({
|
||||
userMessageID: userMessage.id,
|
||||
label: "compaction",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let assistantGroupIndex = 0
|
||||
assistantItems.forEach((item) => {
|
||||
if (item.type === "interrupted") {
|
||||
rows.push(
|
||||
new TimelineRow.TurnDivider({
|
||||
userMessageID: userMessage.id,
|
||||
label: "interrupted",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
rows.push(
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID: userMessage.id,
|
||||
group: item.group,
|
||||
previousAssistantPart: assistantGroupIndex > 0,
|
||||
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
|
||||
}),
|
||||
)
|
||||
assistantGroupIndex += 1
|
||||
})
|
||||
|
||||
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
|
||||
const heading = assistantMessages
|
||||
.flatMap((message) => getMessageParts(message.id))
|
||||
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
|
||||
.find((value): value is string => !!value)
|
||||
|
||||
rows.push(
|
||||
new TimelineRow.Thinking({
|
||||
userMessageID: userMessage.id,
|
||||
reasoningHeading: heading,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
|
||||
|
||||
const diffs = (userMessage.summary?.diffs ?? [])
|
||||
.reduceRight<SummaryDiff[]>((result, diff) => {
|
||||
if (!isSummaryDiff(diff)) return result
|
||||
if (result.some((item) => item.file === diff.file)) return result
|
||||
result.push(diff)
|
||||
return result
|
||||
}, [])
|
||||
.reverse()
|
||||
if (diffs.length > 0 && (status === "idle" || !isActive)) {
|
||||
rows.push(
|
||||
new TimelineRow.DiffSummary({
|
||||
userMessageID: userMessage.id,
|
||||
diffs,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const data = error.data?.message
|
||||
rows.push(
|
||||
new TimelineRow.Error({
|
||||
userMessageID: userMessage.id,
|
||||
text: unwrapErrorMessage(
|
||||
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
function reasoningHeading(text: string) {
|
||||
const markdown = text.replace(/\r\n?/g, "\n")
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
|
||||
if (html?.[1]) {
|
||||
const value = cleanHeading(html[1].replace(/<[^>]+>/g, " "))
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
|
||||
if (atx?.[1]) {
|
||||
const value = cleanHeading(atx[1])
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
|
||||
if (setext?.[1]) {
|
||||
const value = cleanHeading(setext[1])
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
|
||||
if (strong?.[1]) {
|
||||
const value = cleanHeading(strong[1])
|
||||
if (value) return value
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHeading(value: string) {
|
||||
return value
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[*_~]+/g, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function unwrapErrorMessage(message: string) {
|
||||
const text = message.replace(/^Error:\s*/, "").trim()
|
||||
|
||||
const parse = (value: string) => {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const read = (value: string) => {
|
||||
const first = parse(value)
|
||||
if (typeof first !== "string") return first
|
||||
return parse(first.trim())
|
||||
}
|
||||
|
||||
let json = read(text)
|
||||
|
||||
if (json === undefined) {
|
||||
const start = text.indexOf("{")
|
||||
const end = text.lastIndexOf("}")
|
||||
if (start !== -1 && end > start) json = read(text.slice(start, end + 1))
|
||||
}
|
||||
|
||||
if (!record(json)) return message
|
||||
|
||||
const err = record(json.error) ? json.error : undefined
|
||||
if (err) {
|
||||
const type = typeof err.type === "string" ? err.type : undefined
|
||||
const msg = typeof err.message === "string" ? err.message : undefined
|
||||
if (type && msg) return `${type}: ${msg}`
|
||||
if (msg) return msg
|
||||
if (type) return type
|
||||
const code = typeof err.code === "string" ? err.code : undefined
|
||||
if (code) return code
|
||||
}
|
||||
|
||||
const msg = typeof json.message === "string" ? json.message : undefined
|
||||
if (msg) return msg
|
||||
|
||||
const reason = typeof json.error === "string" ? json.error : undefined
|
||||
if (reason) return reason
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace MessageComment {
|
||||
export type MessageComment = {
|
||||
path: string
|
||||
comment: string
|
||||
selection?: {
|
||||
startLine: number
|
||||
endLine: number
|
||||
}
|
||||
}
|
||||
|
||||
export const fromPart = (part: Part): MessageComment | undefined => {
|
||||
if (part.type !== "text" || !part.synthetic) return
|
||||
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
|
||||
if (!next) return
|
||||
return {
|
||||
path: next.path,
|
||||
comment: next.comment,
|
||||
selection: next.selection
|
||||
? {
|
||||
startLine: next.selection.startLine,
|
||||
endLine: next.selection.endLine,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { SessionStatus } from "@opencode-ai/sdk/v2"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { useDialog } from "@opencode-ai/ui/context"
|
||||
import { DialogUsageExceeded } from "@/components/dialog-usage-exceeded"
|
||||
import { useI18n } from "@opencode-ai/ui/context"
|
||||
|
||||
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
|
||||
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
|
||||
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
|
||||
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
|
||||
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
|
||||
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||
|
||||
function goUpsellKeys(status: SessionStatus) {
|
||||
if (status.type !== "retry" || !status.action) return
|
||||
const { action } = status
|
||||
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
|
||||
if (action.reason === "free_tier_limit") {
|
||||
return {
|
||||
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
|
||||
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
|
||||
} as const
|
||||
}
|
||||
if (action.reason === "account_rate_limit") {
|
||||
return {
|
||||
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
|
||||
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
export function useUsageExceededDialogs() {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const { params } = useSessionLayout()
|
||||
const { t, locale } = useI18n()
|
||||
const isEnglish = () => locale() === "en"
|
||||
|
||||
const [goUpsellState, setGoUpsellState] = persisted(
|
||||
Persist.global("go-upsell"),
|
||||
createStore({
|
||||
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null as null | number,
|
||||
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null as null | number,
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null as null | number,
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null as null | number,
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
sdk.event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== params.id) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
const { action } = evt.properties.status
|
||||
if (!action) return
|
||||
if (dialog.active) return
|
||||
|
||||
const keys = goUpsellKeys(evt.properties.status)
|
||||
if (!keys) return
|
||||
|
||||
const seen = goUpsellState[keys.lastSeenAt]
|
||||
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
|
||||
if (goUpsellState[keys.dontShow]) return
|
||||
|
||||
if (action.reason === "free_tier_limit") {
|
||||
dialog.show(() => (
|
||||
<DialogUsageExceeded
|
||||
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
|
||||
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
|
||||
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
|
||||
link={action.link}
|
||||
onClose={(dontShowAgain) => {
|
||||
setGoUpsellState(keys.lastSeenAt, Date.now())
|
||||
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
|
||||
else {
|
||||
void import("../../components/dialog-connect-provider").then((x) =>
|
||||
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
} else if (action.reason === "account_rate_limit") {
|
||||
dialog.show(() => (
|
||||
<DialogUsageExceeded
|
||||
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
|
||||
description={isEnglish() ? action.message : t("dialog.usageExceeded.accountRateLimit.description")}
|
||||
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.accountRateLimit.actionLabel")}
|
||||
link={action.link}
|
||||
onClose={(dontShowAgain) => {
|
||||
setGoUpsellState(keys.lastSeenAt, Date.now())
|
||||
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: {
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
turnStart: () => number
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setTurnStart: (value: number) => void
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
revealMessage?: (id: string) => void
|
||||
scheduleScrollState: (el: HTMLDivElement) => void
|
||||
consumePendingMessage: (key: string) => string | undefined
|
||||
}) => {
|
||||
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||
let pendingKey = ""
|
||||
let clearing = false
|
||||
|
||||
@@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
||||
input.revealMessage?.(id)
|
||||
const el = document.getElementById(input.anchor(id))
|
||||
if (el) return scrollToElement(el, behavior)
|
||||
if (left <= 0) return false
|
||||
@@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
cancel()
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
|
||||
const index = messageIndex().get(message.id) ?? -1
|
||||
if (index !== -1 && index < input.turnStart()) {
|
||||
input.setTurnStart(index)
|
||||
|
||||
queue(() => {
|
||||
seek(message.id, behavior)
|
||||
})
|
||||
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
input.revealMessage?.(message.id)
|
||||
|
||||
if (seek(message.id, behavior)) {
|
||||
updateHash(message.id)
|
||||
@@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
|
||||
visibleUserMessages()
|
||||
input.turnStart()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
if (!targetId) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
[data-component="go-credit-confirm"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
min-width: min(34rem, calc(100vw - var(--space-8)));
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
[data-slot="usage-preview"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-item"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-header"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-label"] {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-value"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-after-value"] {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-progress"] {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-before"],
|
||||
[data-slot="usage-preview-after"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-before"] {
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-after"] {
|
||||
background-color: var(--color-accent);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
[data-slot="usage-preview-reset"] {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
[data-slot="modal-actions"] {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="invite-link-box"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
padding: var(--space-2-5);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 130px;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
min-width: 96px;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="instructions"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
ol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-md);
|
||||
list-style-position: inside;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="go-referral-section"] {
|
||||
[data-component="go-referral-overview"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-6);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="go-referral-overview"] + [data-slot="section-title"] {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
[data-slot="referrals-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-component="empty-state"] {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
[data-slot="referrals-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="referral-amount"] {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&[data-slot="referral-source"] {
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&[data-slot="referral-action"] {
|
||||
text-align: right;
|
||||
font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
|
||||
button {
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&[data-status="applied"] {
|
||||
td:not([data-slot="referral-action"]) {
|
||||
opacity: 0.68;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-status="pending"] {
|
||||
td[data-slot="referral-amount"],
|
||||
td[data-slot="referral-date"] {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
td[data-slot="referral-source"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { action, json, query, useAction, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Modal } from "~/component/modal"
|
||||
import { IconCheck, IconCopy } from "~/component/icon"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
|
||||
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
|
||||
import "./go-referral.css"
|
||||
|
||||
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
|
||||
type GoReferralReward = GoReferralSummary["rewards"][number]
|
||||
type GoLiteSubscription = Awaited<ReturnType<typeof queryLiteSubscription>>
|
||||
type GoReferralUsagePreview = NonNullable<Awaited<ReturnType<typeof Referral.usagePreview>>>
|
||||
type GoReferralUsagePreviewItem = GoReferralUsagePreview["rollingUsage"]
|
||||
|
||||
const emptyUsagePreview = {
|
||||
rollingUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
|
||||
weeklyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
|
||||
monthlyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
|
||||
} satisfies GoReferralUsagePreview
|
||||
|
||||
export const queryGoReferral = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Referral.summary(), workspaceID)
|
||||
}, "go.referral.get")
|
||||
|
||||
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
|
||||
"use server"
|
||||
if (!referralID) return null
|
||||
return withActor(() => Referral.usagePreview({ referralID }), workspaceID)
|
||||
}, "go.referral.usagePreview")
|
||||
|
||||
export const applyGoReferralReward = action(async (workspaceID: string, referralID: string) => {
|
||||
"use server"
|
||||
return json(await withActor(() => Referral.applyReward({ referralID }), workspaceID), {
|
||||
revalidate: [queryGoReferral.key, queryGoReferralUsagePreview.key, queryLiteSubscription.key],
|
||||
})
|
||||
}, "go.referral.reward.apply")
|
||||
|
||||
function currentUsagePreview(usage: { resetInSec: number; usagePercent: number }) {
|
||||
return {
|
||||
beforePercent: usage.usagePercent,
|
||||
afterPercent: usage.usagePercent,
|
||||
resetInSec: usage.resetInSec,
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
if (amount % 100 === 0) return `$${amount / 100}`
|
||||
return `$${(amount / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date, locale: string) {
|
||||
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }).format(new Date(value))
|
||||
}
|
||||
|
||||
function rewardDescriptionKey(source: GoReferralReward["source"]) {
|
||||
if (source === "invitee") return "workspace.referral.reward.description.invitee" as const
|
||||
return "workspace.referral.reward.description.inviter" as const
|
||||
}
|
||||
|
||||
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
|
||||
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
|
||||
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
|
||||
return "workspace.referral.reward.action.view" as const
|
||||
}
|
||||
|
||||
function CopyInviteLink(props: { summary: GoReferralSummary }) {
|
||||
const i18n = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const event = getRequestEvent()
|
||||
const origin = event
|
||||
? new URL(event.request.url).origin
|
||||
: typeof window === "object"
|
||||
? window.location.origin
|
||||
: undefined
|
||||
const inviteUrl = createMemo(() => {
|
||||
const path = `/go?ref=${props.summary.referralCode}`
|
||||
if (!origin) return path
|
||||
return new URL(path, origin).toString()
|
||||
})
|
||||
|
||||
async function copy() {
|
||||
if (typeof navigator !== "object") return
|
||||
await navigator.clipboard.writeText(inviteUrl())
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="invite-link-box">
|
||||
<div>
|
||||
<code title={inviteUrl()}>{inviteUrl()}</code>
|
||||
<button type="button" onClick={copy}>
|
||||
<Show
|
||||
when={copied()}
|
||||
fallback={
|
||||
<>
|
||||
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copyLink")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copied")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoReferralSection(props: {
|
||||
workspaceID: string
|
||||
summary: GoReferralSummary
|
||||
lite: GoLiteSubscription | undefined
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const apply = useAction(applyGoReferralReward)
|
||||
const submission = useSubmission(applyGoReferralReward)
|
||||
const [selected, setSelected] = createSignal<GoReferralReward>()
|
||||
const [preview, setPreview] = createSignal<GoReferralUsagePreview | null>()
|
||||
const displayPreview = createMemo(() => {
|
||||
const loaded = preview()
|
||||
if (loaded) return loaded
|
||||
const current = props.lite
|
||||
if (!current) return emptyUsagePreview
|
||||
return {
|
||||
rollingUsage: currentUsagePreview(current.rollingUsage),
|
||||
weeklyUsage: currentUsagePreview(current.weeklyUsage),
|
||||
monthlyUsage: currentUsagePreview(current.monthlyUsage),
|
||||
} satisfies GoReferralUsagePreview
|
||||
})
|
||||
createEffect(() => {
|
||||
const reward = selected()
|
||||
if (!reward) {
|
||||
setPreview(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const request = { cancelled: false }
|
||||
setPreview(undefined)
|
||||
queryGoReferralUsagePreview(props.workspaceID, reward.id).then((result) => {
|
||||
if (request.cancelled) return
|
||||
setPreview(result)
|
||||
})
|
||||
onCleanup(() => {
|
||||
request.cancelled = true
|
||||
})
|
||||
})
|
||||
|
||||
async function onApply() {
|
||||
const reward = selected()
|
||||
if (!reward) return
|
||||
await apply(props.workspaceID, reward.id)
|
||||
setSelected(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.lite || props.summary.hasReferral}>
|
||||
<section data-component="go-referral-section">
|
||||
<Show when={props.lite}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.referral.overview.title")}</h2>
|
||||
<p>{i18n.t("workspace.referral.overview.subtitle")}</p>
|
||||
</div>
|
||||
<div data-component="go-referral-overview">
|
||||
<CopyInviteLink summary={props.summary} />
|
||||
<div data-slot="instructions">
|
||||
<ol>
|
||||
<li>{i18n.t("workspace.referral.instructions.share")}</li>
|
||||
<li>{i18n.t("workspace.referral.instructions.subscribe")}</li>
|
||||
<li>{i18n.t("workspace.referral.instructions.claim")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.summary.hasReferral}>
|
||||
<div data-slot="section-title">
|
||||
<h2>{i18n.t("workspace.referral.rewards.title")}</h2>
|
||||
<p>{i18n.t("workspace.referral.rewards.description")}</p>
|
||||
</div>
|
||||
<div data-slot="referrals-table">
|
||||
<table data-slot="referrals-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.referral.table.reward")}</th>
|
||||
<th>{i18n.t("workspace.referral.table.referral")}</th>
|
||||
<th>{i18n.t("workspace.referral.table.date")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.summary.rewards}>
|
||||
{(reward) => {
|
||||
const earnedAt = () => formatDate(reward.timeCreated, language.tag(language.locale()))
|
||||
return (
|
||||
<tr data-status={reward.status} data-source={reward.source}>
|
||||
<td data-slot="referral-amount">{formatCurrency(reward.amount)}</td>
|
||||
<td data-slot="referral-source">
|
||||
{i18n.t(rewardDescriptionKey(reward.source), { email: reward.email ?? "" })}
|
||||
</td>
|
||||
<td data-slot="referral-date" title={earnedAt()}>
|
||||
{earnedAt()}
|
||||
</td>
|
||||
<td data-slot="referral-action">
|
||||
<button
|
||||
type="button"
|
||||
disabled={reward.status !== "available" || !props.lite || submission.pending}
|
||||
onClick={() => setSelected(reward)}
|
||||
>
|
||||
{i18n.t(rewardActionKey(reward, !!props.lite))}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Modal
|
||||
open={!!selected()}
|
||||
onClose={() => setSelected(undefined)}
|
||||
title={i18n.t("workspace.referral.apply.confirmTitle")}
|
||||
>
|
||||
<div data-component="go-credit-confirm">
|
||||
<p>
|
||||
{i18n.t("workspace.referral.apply.confirmBody", {
|
||||
amount: formatCurrency(selected()?.amount ?? 0),
|
||||
})}
|
||||
</p>
|
||||
<GoReferralUsagePreview preview={displayPreview()} />
|
||||
<div data-slot="modal-actions">
|
||||
<button type="button" onClick={() => setSelected(undefined)}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="button" data-color="primary" disabled={submission.pending} onClick={onApply}>
|
||||
{submission.pending ? i18n.t("workspace.lite.loading") : i18n.t("workspace.referral.apply.confirmAction")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GoReferralUsagePreview(props: { preview: GoReferralUsagePreview }) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<div data-slot="usage-preview">
|
||||
<GoReferralUsagePreviewRow
|
||||
label={i18n.t("workspace.lite.subscription.rollingUsage")}
|
||||
usage={props.preview.rollingUsage}
|
||||
/>
|
||||
<GoReferralUsagePreviewRow
|
||||
label={i18n.t("workspace.lite.subscription.weeklyUsage")}
|
||||
usage={props.preview.weeklyUsage}
|
||||
/>
|
||||
<GoReferralUsagePreviewRow
|
||||
label={i18n.t("workspace.lite.subscription.monthlyUsage")}
|
||||
usage={props.preview.monthlyUsage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GoReferralUsagePreviewRow(props: { label: string; usage: GoReferralUsagePreviewItem }) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<div data-slot="usage-preview-item">
|
||||
<div data-slot="usage-preview-header">
|
||||
<span data-slot="usage-preview-label">{props.label}</span>
|
||||
<span data-slot="usage-preview-value">
|
||||
<span>{props.usage.beforePercent}%</span>
|
||||
<span aria-hidden="true">-></span>
|
||||
<span data-slot="usage-preview-after-value">{props.usage.afterPercent}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="usage-preview-progress">
|
||||
<div data-slot="usage-preview-before" style={{ width: `${props.usage.beforePercent}%` }} />
|
||||
<div data-slot="usage-preview-after" style={{ width: `${props.usage.afterPercent}%` }} />
|
||||
</div>
|
||||
<span data-slot="usage-preview-reset">
|
||||
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,61 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-hover);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&[data-color="primary"] {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-color="ghost"] {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-hover);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
@@ -64,4 +119,16 @@
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-slot="content"][data-variant="black"] {
|
||||
background-color: #000;
|
||||
border-color: rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
[data-slot="title"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Dialog as Kobalte } from "@kobalte/core/dialog"
|
||||
import { JSX, Show } from "solid-js"
|
||||
import "./modal.css"
|
||||
|
||||
@@ -5,20 +6,41 @@ interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
variant?: "black"
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div data-component="modal" data-slot="overlay" onClick={props.onClose}>
|
||||
<div data-slot="content" onClick={(e) => e.stopPropagation()}>
|
||||
<Show when={props.title}>
|
||||
<h2 data-slot="title">{props.title}</h2>
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
<Kobalte
|
||||
modal
|
||||
open={props.open}
|
||||
preventScroll={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) props.onClose()
|
||||
}}
|
||||
>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Overlay data-component="modal" data-slot="overlay" onClick={props.onClose}>
|
||||
<Kobalte.Content
|
||||
data-slot="content"
|
||||
data-variant={props.variant}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
const target = e.currentTarget as HTMLElement | null
|
||||
target?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<Show when={props.title}>
|
||||
<Kobalte.Title data-slot="title">{props.title}</Kobalte.Title>
|
||||
</Show>
|
||||
{props.children}
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Overlay>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -660,6 +660,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
|
||||
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
|
||||
|
||||
"workspace.referral.copyLink": "نسخ الرابط",
|
||||
"workspace.referral.copied": "تم النسخ",
|
||||
"workspace.referral.overview.title": "ادعُ أصدقاءك",
|
||||
"workspace.referral.overview.subtitle": "احصل على $5 عند اشتراك صديق. وسيحصل هو أيضًا على $5.",
|
||||
"workspace.referral.instructions.share": "شارك رابط الإحالة الخاص بك",
|
||||
"workspace.referral.instructions.subscribe": "ينضم صديقك ويشترك في Go",
|
||||
"workspace.referral.instructions.claim": "تحصلان كلاكما على رصيد استخدام بقيمة $5 لتطبيقه على حدود استخدام Go",
|
||||
"workspace.referral.rewards.title": "مكافآت الإحالة",
|
||||
"workspace.referral.rewards.description": "طبّق أرصدة الإحالة المتاحة على استخدامك لـ Go.",
|
||||
"workspace.referral.rewards.subtitle": "تم تطبيق {{applied}} / {{total}} من المكافآت.",
|
||||
"workspace.referral.rewards.empty": "لا توجد مكافآت إحالة بعد.",
|
||||
"workspace.referral.table.reward": "المكافأة",
|
||||
"workspace.referral.table.referral": "الوصف",
|
||||
"workspace.referral.table.date": "التاريخ",
|
||||
"workspace.referral.reward.description.inviter": "تمت دعوة {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "تمت دعوتك بواسطة {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "اشترك لإلغاء القفل",
|
||||
"workspace.referral.reward.action.view": "عرض المكافأة",
|
||||
"workspace.referral.reward.action.applied": "تم تطبيق المكافأة",
|
||||
"workspace.referral.reward.source.pendingInviter": "بانتظار اشتراكه",
|
||||
"workspace.referral.reward.source.pendingInvitee": "اشترك لإلغاء قفل المكافأة",
|
||||
"workspace.referral.reward.source.available": "المكافأة جاهزة للتطبيق",
|
||||
"workspace.referral.reward.source.applied": "تم تطبيق المكافأة",
|
||||
"workspace.referral.reward.status.applied": "تم تطبيق المكافأة",
|
||||
"workspace.referral.reward.status.pendingInviter": "اشترك لإلغاء القفل",
|
||||
"workspace.referral.reward.status.pendingInvitee": "اشترك لإلغاء القفل",
|
||||
"workspace.referral.apply.noGo": "اشترك لإلغاء القفل",
|
||||
"workspace.referral.apply.preview": "عرض المكافأة",
|
||||
"workspace.referral.apply.action": "تطبيق",
|
||||
"workspace.referral.apply.confirmTitle": "تطبيق المكافأة",
|
||||
"workspace.referral.apply.confirmBody": "طبِّق {{amount}} لتقليل الاستخدام الحالي في مساحة العمل هذه.",
|
||||
"workspace.referral.apply.confirmAction": "تطبيق",
|
||||
|
||||
"download.title": "OpenCode | تنزيل",
|
||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||
"download.hero.title": "تنزيل OpenCode",
|
||||
|
||||
@@ -670,6 +670,40 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
|
||||
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
|
||||
|
||||
"workspace.referral.copyLink": "Copiar link",
|
||||
"workspace.referral.copied": "Copiado",
|
||||
"workspace.referral.overview.title": "Convide amigos",
|
||||
"workspace.referral.overview.subtitle": "Ganhe $5 quando um amigo assinar. Ele também ganha $5.",
|
||||
"workspace.referral.instructions.share": "Compartilhe seu link de indicação",
|
||||
"workspace.referral.instructions.subscribe": "Seu amigo entra e assina o Go",
|
||||
"workspace.referral.instructions.claim":
|
||||
"Vocês dois ganham um crédito de uso de $5 para aplicar aos seus limites de uso do Go",
|
||||
"workspace.referral.rewards.title": "Recompensas de indicação",
|
||||
"workspace.referral.rewards.description": "Aplique os créditos de indicação disponíveis no seu uso do Go.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
|
||||
"workspace.referral.rewards.empty": "Ainda não há recompensas de indicação.",
|
||||
"workspace.referral.table.reward": "Recompensa",
|
||||
"workspace.referral.table.referral": "Descrição",
|
||||
"workspace.referral.table.date": "Data",
|
||||
"workspace.referral.reward.description.inviter": "Convidou {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Convidado por {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Assine para desbloquear",
|
||||
"workspace.referral.reward.action.view": "Ver recompensa",
|
||||
"workspace.referral.reward.action.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.source.pendingInviter": "Aguardando ele assinar",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Assine para desbloquear a recompensa",
|
||||
"workspace.referral.reward.source.available": "Recompensa pronta para usar",
|
||||
"workspace.referral.reward.source.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.status.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.status.pendingInviter": "Assine para desbloquear",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Assine para desbloquear",
|
||||
"workspace.referral.apply.noGo": "Assine para desbloquear",
|
||||
"workspace.referral.apply.preview": "Ver recompensa",
|
||||
"workspace.referral.apply.action": "Aplicar",
|
||||
"workspace.referral.apply.confirmTitle": "Aplicar recompensa",
|
||||
"workspace.referral.apply.confirmBody": "Aplique {{amount}} para reduzir o uso atual deste workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Aplicar",
|
||||
|
||||
"download.title": "OpenCode | Baixar",
|
||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||
"download.hero.title": "Baixar OpenCode",
|
||||
|
||||
@@ -666,6 +666,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
|
||||
|
||||
"workspace.referral.copyLink": "Kopiér link",
|
||||
"workspace.referral.copied": "Kopieret",
|
||||
"workspace.referral.overview.title": "Inviter venner",
|
||||
"workspace.referral.overview.subtitle": "Få $5, når en ven abonnerer. De får også $5.",
|
||||
"workspace.referral.instructions.share": "Del dit henvisningslink",
|
||||
"workspace.referral.instructions.subscribe": "Din ven tilmelder sig og abonnerer på Go",
|
||||
"workspace.referral.instructions.claim": "I får begge $5 i forbrugskredit til at bruge på jeres Go-forbrugsgrænser",
|
||||
"workspace.referral.rewards.title": "Henvisningsbelønninger",
|
||||
"workspace.referral.rewards.description": "Brug tilgængelige henvisningskreditter på dit Go-forbrug.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brugt.",
|
||||
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger endnu.",
|
||||
"workspace.referral.table.reward": "Belønning",
|
||||
"workspace.referral.table.referral": "Beskrivelse",
|
||||
"workspace.referral.table.date": "Dato",
|
||||
"workspace.referral.reward.description.inviter": "Inviterede {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Inviteret af {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Abonner for at låse op",
|
||||
"workspace.referral.reward.action.view": "Vis belønning",
|
||||
"workspace.referral.reward.action.applied": "Belønning brugt",
|
||||
"workspace.referral.reward.source.pendingInviter": "Venter på, at de abonnerer",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonner for at låse belønningen op",
|
||||
"workspace.referral.reward.source.available": "Belønning klar til brug",
|
||||
"workspace.referral.reward.source.applied": "Belønning brugt",
|
||||
"workspace.referral.reward.status.applied": "Belønning brugt",
|
||||
"workspace.referral.reward.status.pendingInviter": "Abonner for at låse op",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Abonner for at låse op",
|
||||
"workspace.referral.apply.noGo": "Abonner for at låse op",
|
||||
"workspace.referral.apply.preview": "Vis belønning",
|
||||
"workspace.referral.apply.action": "Brug",
|
||||
"workspace.referral.apply.confirmTitle": "Brug belønning",
|
||||
"workspace.referral.apply.confirmBody": "Brug {{amount}} til at reducere dette workspaces nuværende forbrug.",
|
||||
"workspace.referral.apply.confirmAction": "Brug",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -669,6 +669,41 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
|
||||
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
|
||||
|
||||
"workspace.referral.copyLink": "Link kopieren",
|
||||
"workspace.referral.copied": "Kopiert",
|
||||
"workspace.referral.overview.title": "Freunde einladen",
|
||||
"workspace.referral.overview.subtitle": "Erhalte $5, wenn ein Freund abonniert. Er bekommt ebenfalls $5.",
|
||||
"workspace.referral.instructions.share": "Teile deinen Empfehlungslink",
|
||||
"workspace.referral.instructions.subscribe": "Dein Freund tritt bei und abonniert Go",
|
||||
"workspace.referral.instructions.claim":
|
||||
"Ihr erhaltet beide ein Nutzungsguthaben von $5, das ihr auf eure Go-Nutzungslimits anrechnen könnt",
|
||||
"workspace.referral.rewards.title": "Empfehlungsbelohnungen",
|
||||
"workspace.referral.rewards.description": "Verfügbare Empfehlungsguthaben auf deine Go-Nutzung anwenden.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} Belohnungen eingelöst.",
|
||||
"workspace.referral.rewards.empty": "Noch keine Empfehlungsbelohnungen.",
|
||||
"workspace.referral.table.reward": "Belohnung",
|
||||
"workspace.referral.table.referral": "Beschreibung",
|
||||
"workspace.referral.table.date": "Datum",
|
||||
"workspace.referral.reward.description.inviter": "{{email}} eingeladen",
|
||||
"workspace.referral.reward.description.invitee": "Eingeladen von {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Abonnieren zum Freischalten",
|
||||
"workspace.referral.reward.action.view": "Belohnung ansehen",
|
||||
"workspace.referral.reward.action.applied": "Belohnung eingelöst",
|
||||
"workspace.referral.reward.source.pendingInviter": "Warten auf das Abo des Freundes",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonnieren, um Belohnung freizuschalten",
|
||||
"workspace.referral.reward.source.available": "Belohnung kann eingelöst werden",
|
||||
"workspace.referral.reward.source.applied": "Belohnung eingelöst",
|
||||
"workspace.referral.reward.status.applied": "Belohnung eingelöst",
|
||||
"workspace.referral.reward.status.pendingInviter": "Abonnieren zum Freischalten",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Abonnieren zum Freischalten",
|
||||
"workspace.referral.apply.noGo": "Abonnieren zum Freischalten",
|
||||
"workspace.referral.apply.preview": "Belohnung ansehen",
|
||||
"workspace.referral.apply.action": "Einlösen",
|
||||
"workspace.referral.apply.confirmTitle": "Belohnung einlösen",
|
||||
"workspace.referral.apply.confirmBody":
|
||||
"Löse {{amount}} ein, um die aktuelle Nutzung dieses Workspace zu reduzieren.",
|
||||
"workspace.referral.apply.confirmAction": "Einlösen",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||
"download.hero.title": "OpenCode herunterladen",
|
||||
|
||||
@@ -662,6 +662,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Other payment methods",
|
||||
"workspace.lite.promo.selectMethod": "Select payment method",
|
||||
|
||||
"workspace.referral.copyLink": "Copy Link",
|
||||
"workspace.referral.copied": "Copied",
|
||||
"workspace.referral.overview.title": "Invite friends",
|
||||
"workspace.referral.overview.subtitle": "Earn $5 when a friend subscribes. They’ll get $5 too.",
|
||||
"workspace.referral.instructions.share": "Share your referral link",
|
||||
"workspace.referral.instructions.subscribe": "Your friend joins and subscribes to Go",
|
||||
"workspace.referral.instructions.claim": "You both get a $5 usage credit to apply toward your Go usage limits",
|
||||
"workspace.referral.rewards.title": "Referral rewards",
|
||||
"workspace.referral.rewards.description": "Apply available referral credits toward your Go usage.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} rewards applied.",
|
||||
"workspace.referral.rewards.empty": "No referral rewards yet.",
|
||||
"workspace.referral.table.reward": "Reward",
|
||||
"workspace.referral.table.referral": "Description",
|
||||
"workspace.referral.table.date": "Date",
|
||||
"workspace.referral.reward.description.inviter": "Invited {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Invited by {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Subscribe to unlock",
|
||||
"workspace.referral.reward.action.view": "View Reward",
|
||||
"workspace.referral.reward.action.applied": "Reward Applied",
|
||||
"workspace.referral.reward.source.pendingInviter": "Waiting for them to subscribe",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Subscribe to unlock reward",
|
||||
"workspace.referral.reward.source.available": "Reward ready to apply",
|
||||
"workspace.referral.reward.source.applied": "Reward applied",
|
||||
"workspace.referral.reward.status.applied": "Reward Applied",
|
||||
"workspace.referral.reward.status.pendingInviter": "Subscribe to unlock",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Subscribe to unlock",
|
||||
"workspace.referral.apply.noGo": "Subscribe to unlock",
|
||||
"workspace.referral.apply.preview": "View Reward",
|
||||
"workspace.referral.apply.action": "Apply",
|
||||
"workspace.referral.apply.confirmTitle": "Apply reward",
|
||||
"workspace.referral.apply.confirmBody": "Apply {{amount}} to reduce this workspace's current usage.",
|
||||
"workspace.referral.apply.confirmAction": "Apply",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||
"download.hero.title": "Download OpenCode",
|
||||
|
||||
@@ -670,6 +670,40 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Otros métodos de pago",
|
||||
"workspace.lite.promo.selectMethod": "Seleccionar método de pago",
|
||||
|
||||
"workspace.referral.copyLink": "Copiar enlace",
|
||||
"workspace.referral.copied": "Copiado",
|
||||
"workspace.referral.overview.title": "Invita amigos",
|
||||
"workspace.referral.overview.subtitle": "Gana $5 cuando un amigo se suscriba. Él también recibirá $5.",
|
||||
"workspace.referral.instructions.share": "Comparte tu enlace de referido",
|
||||
"workspace.referral.instructions.subscribe": "Tu amigo se une y se suscribe a Go",
|
||||
"workspace.referral.instructions.claim":
|
||||
"Ambos reciben un crédito de uso de $5 para aplicar a sus límites de uso de Go",
|
||||
"workspace.referral.rewards.title": "Recompensas por referidos",
|
||||
"workspace.referral.rewards.description": "Aplica los créditos por referidos disponibles a tu uso de Go.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
|
||||
"workspace.referral.rewards.empty": "Aún no hay recompensas por referidos.",
|
||||
"workspace.referral.table.reward": "Recompensa",
|
||||
"workspace.referral.table.referral": "Descripción",
|
||||
"workspace.referral.table.date": "Fecha",
|
||||
"workspace.referral.reward.description.inviter": "Invitaste a {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Invitado por {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Suscríbete para desbloquear",
|
||||
"workspace.referral.reward.action.view": "Ver recompensa",
|
||||
"workspace.referral.reward.action.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.source.pendingInviter": "Esperando a que se suscriba",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Suscríbete para desbloquear la recompensa",
|
||||
"workspace.referral.reward.source.available": "Recompensa lista para aplicar",
|
||||
"workspace.referral.reward.source.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.status.applied": "Recompensa aplicada",
|
||||
"workspace.referral.reward.status.pendingInviter": "Suscríbete para desbloquear",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Suscríbete para desbloquear",
|
||||
"workspace.referral.apply.noGo": "Suscríbete para desbloquear",
|
||||
"workspace.referral.apply.preview": "Ver recompensa",
|
||||
"workspace.referral.apply.action": "Aplicar",
|
||||
"workspace.referral.apply.confirmTitle": "Aplicar recompensa",
|
||||
"workspace.referral.apply.confirmBody": "Aplica {{amount}} para reducir el uso actual de este workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Aplicar",
|
||||
|
||||
"download.title": "OpenCode | Descargar",
|
||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||
"download.hero.title": "Descargar OpenCode",
|
||||
|
||||
@@ -676,6 +676,41 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
|
||||
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
|
||||
|
||||
"workspace.referral.copyLink": "Copier le lien",
|
||||
"workspace.referral.copied": "Copié",
|
||||
"workspace.referral.overview.title": "Inviter des amis",
|
||||
"workspace.referral.overview.subtitle": "Gagnez $5 lorsqu'un ami s'abonne. Il recevra également $5.",
|
||||
"workspace.referral.instructions.share": "Partagez votre lien de parrainage",
|
||||
"workspace.referral.instructions.subscribe": "Votre ami rejoint et s'abonne à Go",
|
||||
"workspace.referral.instructions.claim":
|
||||
"Vous recevez tous les deux un crédit d'utilisation de $5 à appliquer à vos limites d'utilisation Go",
|
||||
"workspace.referral.rewards.title": "Récompenses de parrainage",
|
||||
"workspace.referral.rewards.description":
|
||||
"Utilisez les crédits de parrainage disponibles pour votre utilisation de Go.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} récompenses utilisées.",
|
||||
"workspace.referral.rewards.empty": "Aucune récompense de parrainage pour l'instant.",
|
||||
"workspace.referral.table.reward": "Récompense",
|
||||
"workspace.referral.table.referral": "Description",
|
||||
"workspace.referral.table.date": "Date",
|
||||
"workspace.referral.reward.description.inviter": "Vous avez invité {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Invité par {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Abonnez-vous pour débloquer",
|
||||
"workspace.referral.reward.action.view": "Voir la récompense",
|
||||
"workspace.referral.reward.action.applied": "Récompense utilisée",
|
||||
"workspace.referral.reward.source.pendingInviter": "En attente de son abonnement",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonnez-vous pour débloquer la récompense",
|
||||
"workspace.referral.reward.source.available": "Récompense prête à utiliser",
|
||||
"workspace.referral.reward.source.applied": "Récompense utilisée",
|
||||
"workspace.referral.reward.status.applied": "Récompense utilisée",
|
||||
"workspace.referral.reward.status.pendingInviter": "Abonnez-vous pour débloquer",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Abonnez-vous pour débloquer",
|
||||
"workspace.referral.apply.noGo": "Abonnez-vous pour débloquer",
|
||||
"workspace.referral.apply.preview": "Voir la récompense",
|
||||
"workspace.referral.apply.action": "Utiliser",
|
||||
"workspace.referral.apply.confirmTitle": "Utiliser la récompense",
|
||||
"workspace.referral.apply.confirmBody": "Utilisez {{amount}} pour réduire l'utilisation actuelle de ce workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Utiliser",
|
||||
|
||||
"download.title": "OpenCode | Téléchargement",
|
||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||
"download.hero.title": "Télécharger OpenCode",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { dict as da } from "~/i18n/da"
|
||||
import { dict as ja } from "~/i18n/ja"
|
||||
import { dict as pl } from "~/i18n/pl"
|
||||
import { dict as ru } from "~/i18n/ru"
|
||||
import { dict as uk } from "~/i18n/uk"
|
||||
import { dict as ar } from "~/i18n/ar"
|
||||
import { dict as no } from "~/i18n/no"
|
||||
import { dict as br } from "~/i18n/br"
|
||||
@@ -35,6 +36,7 @@ export function i18n(locale: Locale): Dict {
|
||||
if (locale === "ja") return { ...base, ...ja }
|
||||
if (locale === "pl") return { ...base, ...pl }
|
||||
if (locale === "ru") return { ...base, ...ru }
|
||||
if (locale === "uk") return { ...base, ...uk }
|
||||
if (locale === "ar") return { ...base, ...ar }
|
||||
if (locale === "no") return { ...base, ...no }
|
||||
if (locale === "br") return { ...base, ...br }
|
||||
|
||||
@@ -668,6 +668,40 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
|
||||
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
|
||||
|
||||
"workspace.referral.copyLink": "Copia link",
|
||||
"workspace.referral.copied": "Copiato",
|
||||
"workspace.referral.overview.title": "Invita amici",
|
||||
"workspace.referral.overview.subtitle": "Guadagna $5 quando un amico si abbona. Anche lui riceverà $5.",
|
||||
"workspace.referral.instructions.share": "Condividi il tuo link di referral",
|
||||
"workspace.referral.instructions.subscribe": "Il tuo amico si iscrive e si abbona a Go",
|
||||
"workspace.referral.instructions.claim":
|
||||
"Entrambi ricevete un credito di utilizzo di $5 da applicare ai vostri limiti di utilizzo Go",
|
||||
"workspace.referral.rewards.title": "Premi referral",
|
||||
"workspace.referral.rewards.description": "Applica i crediti referral disponibili al tuo utilizzo di Go.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} premi utilizzati.",
|
||||
"workspace.referral.rewards.empty": "Nessun premio referral ancora.",
|
||||
"workspace.referral.table.reward": "Premio",
|
||||
"workspace.referral.table.referral": "Descrizione",
|
||||
"workspace.referral.table.date": "Data",
|
||||
"workspace.referral.reward.description.inviter": "Hai invitato {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Invitato da {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Abbonati per sbloccare",
|
||||
"workspace.referral.reward.action.view": "Vedi premio",
|
||||
"workspace.referral.reward.action.applied": "Premio utilizzato",
|
||||
"workspace.referral.reward.source.pendingInviter": "In attesa che si abboni",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abbonati per sbloccare il premio",
|
||||
"workspace.referral.reward.source.available": "Premio pronto da utilizzare",
|
||||
"workspace.referral.reward.source.applied": "Premio utilizzato",
|
||||
"workspace.referral.reward.status.applied": "Premio utilizzato",
|
||||
"workspace.referral.reward.status.pendingInviter": "Abbonati per sbloccare",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Abbonati per sbloccare",
|
||||
"workspace.referral.apply.noGo": "Abbonati per sbloccare",
|
||||
"workspace.referral.apply.preview": "Vedi premio",
|
||||
"workspace.referral.apply.action": "Utilizza",
|
||||
"workspace.referral.apply.confirmTitle": "Utilizza premio",
|
||||
"workspace.referral.apply.confirmBody": "Utilizza {{amount}} per ridurre l'utilizzo attuale di questo workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Utilizza",
|
||||
|
||||
"download.title": "OpenCode | Download",
|
||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||
"download.hero.title": "Scarica OpenCode",
|
||||
|
||||
@@ -668,6 +668,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "その他の支払い方法",
|
||||
"workspace.lite.promo.selectMethod": "支払い方法を選択",
|
||||
|
||||
"workspace.referral.copyLink": "リンクをコピー",
|
||||
"workspace.referral.copied": "コピーしました",
|
||||
"workspace.referral.overview.title": "友達を招待",
|
||||
"workspace.referral.overview.subtitle": "友達がサブスクライブすると $5 を獲得。友達にも $5 が付与されます。",
|
||||
"workspace.referral.instructions.share": "リファラルリンクをシェア",
|
||||
"workspace.referral.instructions.subscribe": "友達が参加して Go にサブスクライブ",
|
||||
"workspace.referral.instructions.claim": "二人とも $5 の利用クレジットを獲得し、Go の利用上限に充当できます",
|
||||
"workspace.referral.rewards.title": "リファラル特典",
|
||||
"workspace.referral.rewards.description": "利用可能なリファラルクレジットを Go の利用に適用します。",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} 件の特典を適用済み。",
|
||||
"workspace.referral.rewards.empty": "リファラル特典はまだありません。",
|
||||
"workspace.referral.table.reward": "特典",
|
||||
"workspace.referral.table.referral": "説明",
|
||||
"workspace.referral.table.date": "日付",
|
||||
"workspace.referral.reward.description.inviter": "{{email}} を招待しました",
|
||||
"workspace.referral.reward.description.invitee": "{{email}} に招待されました",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "サブスクライブしてアンロック",
|
||||
"workspace.referral.reward.action.view": "特典を表示",
|
||||
"workspace.referral.reward.action.applied": "特典を適用済み",
|
||||
"workspace.referral.reward.source.pendingInviter": "友達のサブスクライブ待ち",
|
||||
"workspace.referral.reward.source.pendingInvitee": "サブスクライブして特典をアンロック",
|
||||
"workspace.referral.reward.source.available": "特典は適用可能です",
|
||||
"workspace.referral.reward.source.applied": "特典を適用済み",
|
||||
"workspace.referral.reward.status.applied": "特典を適用済み",
|
||||
"workspace.referral.reward.status.pendingInviter": "サブスクライブしてアンロック",
|
||||
"workspace.referral.reward.status.pendingInvitee": "サブスクライブしてアンロック",
|
||||
"workspace.referral.apply.noGo": "サブスクライブしてアンロック",
|
||||
"workspace.referral.apply.preview": "特典を表示",
|
||||
"workspace.referral.apply.action": "適用",
|
||||
"workspace.referral.apply.confirmTitle": "特典を適用",
|
||||
"workspace.referral.apply.confirmBody": "{{amount}} を適用して、このワークスペースの現在の使用量を減らします。",
|
||||
"workspace.referral.apply.confirmAction": "適用",
|
||||
|
||||
"download.title": "OpenCode | ダウンロード",
|
||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||
"download.hero.title": "OpenCode をダウンロード",
|
||||
|
||||
@@ -660,6 +660,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "기타 결제 수단",
|
||||
"workspace.lite.promo.selectMethod": "결제 수단 선택",
|
||||
|
||||
"workspace.referral.copyLink": "링크 복사",
|
||||
"workspace.referral.copied": "복사됨",
|
||||
"workspace.referral.overview.title": "친구 초대",
|
||||
"workspace.referral.overview.subtitle": "친구가 구독하면 $5를 받으세요. 친구도 $5를 받습니다.",
|
||||
"workspace.referral.instructions.share": "추천 링크 공유",
|
||||
"workspace.referral.instructions.subscribe": "친구가 가입하고 Go를 구독",
|
||||
"workspace.referral.instructions.claim": "두 분 모두 $5 사용 크레딧을 받아 Go 사용 한도에 적용할 수 있습니다",
|
||||
"workspace.referral.rewards.title": "추천 보상",
|
||||
"workspace.referral.rewards.description": "사용 가능한 추천 크레딧을 Go 사용량에 적용합니다.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}}개 보상 사용됨.",
|
||||
"workspace.referral.rewards.empty": "아직 추천 보상이 없습니다.",
|
||||
"workspace.referral.table.reward": "보상",
|
||||
"workspace.referral.table.referral": "설명",
|
||||
"workspace.referral.table.date": "날짜",
|
||||
"workspace.referral.reward.description.inviter": "{{email}} 초대됨",
|
||||
"workspace.referral.reward.description.invitee": "{{email}}님이 초대",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "구독하여 잠금 해제",
|
||||
"workspace.referral.reward.action.view": "보상 보기",
|
||||
"workspace.referral.reward.action.applied": "보상 사용됨",
|
||||
"workspace.referral.reward.source.pendingInviter": "친구의 구독을 기다리는 중",
|
||||
"workspace.referral.reward.source.pendingInvitee": "구독하여 보상 잠금 해제",
|
||||
"workspace.referral.reward.source.available": "보상 사용 가능",
|
||||
"workspace.referral.reward.source.applied": "보상 사용됨",
|
||||
"workspace.referral.reward.status.applied": "보상 사용됨",
|
||||
"workspace.referral.reward.status.pendingInviter": "구독하여 잠금 해제",
|
||||
"workspace.referral.reward.status.pendingInvitee": "구독하여 잠금 해제",
|
||||
"workspace.referral.apply.noGo": "구독하여 잠금 해제",
|
||||
"workspace.referral.apply.preview": "보상 보기",
|
||||
"workspace.referral.apply.action": "사용",
|
||||
"workspace.referral.apply.confirmTitle": "보상 사용",
|
||||
"workspace.referral.apply.confirmBody": "{{amount}}를 사용하여 이 워크스페이스의 현재 사용량을 줄입니다.",
|
||||
"workspace.referral.apply.confirmAction": "사용",
|
||||
|
||||
"download.title": "OpenCode | 다운로드",
|
||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||
"download.hero.title": "OpenCode 다운로드",
|
||||
|
||||
@@ -667,6 +667,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
|
||||
|
||||
"workspace.referral.copyLink": "Kopier lenke",
|
||||
"workspace.referral.copied": "Kopiert",
|
||||
"workspace.referral.overview.title": "Inviter venner",
|
||||
"workspace.referral.overview.subtitle": "Få $5 når en venn abonnerer. De får også $5.",
|
||||
"workspace.referral.instructions.share": "Del henvisningslenken din",
|
||||
"workspace.referral.instructions.subscribe": "Vennen din blir med og abonnerer på Go",
|
||||
"workspace.referral.instructions.claim": "Dere får begge $5 i brukskreditt å bruke på Go-bruksgrensene deres",
|
||||
"workspace.referral.rewards.title": "Henvisningsbelønninger",
|
||||
"workspace.referral.rewards.description": "Bruk tilgjengelige henvisningskreditter på Go-bruken din.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brukt.",
|
||||
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger ennå.",
|
||||
"workspace.referral.table.reward": "Belønning",
|
||||
"workspace.referral.table.referral": "Beskrivelse",
|
||||
"workspace.referral.table.date": "Dato",
|
||||
"workspace.referral.reward.description.inviter": "Inviterte {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Invitert av {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Abonner for å låse opp",
|
||||
"workspace.referral.reward.action.view": "Vis belønning",
|
||||
"workspace.referral.reward.action.applied": "Belønning brukt",
|
||||
"workspace.referral.reward.source.pendingInviter": "Venter på at de abonnerer",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Abonner for å låse opp belønningen",
|
||||
"workspace.referral.reward.source.available": "Belønning klar til bruk",
|
||||
"workspace.referral.reward.source.applied": "Belønning brukt",
|
||||
"workspace.referral.reward.status.applied": "Belønning brukt",
|
||||
"workspace.referral.reward.status.pendingInviter": "Abonner for å låse opp",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Abonner for å låse opp",
|
||||
"workspace.referral.apply.noGo": "Abonner for å låse opp",
|
||||
"workspace.referral.apply.preview": "Vis belønning",
|
||||
"workspace.referral.apply.action": "Bruk",
|
||||
"workspace.referral.apply.confirmTitle": "Bruk belønning",
|
||||
"workspace.referral.apply.confirmBody": "Bruk {{amount}} for å redusere dette workspacets nåværende forbruk.",
|
||||
"workspace.referral.apply.confirmAction": "Bruk",
|
||||
|
||||
"download.title": "OpenCode | Last ned",
|
||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||
"download.hero.title": "Last ned OpenCode",
|
||||
|
||||
@@ -668,6 +668,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Inne metody płatności",
|
||||
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
|
||||
|
||||
"workspace.referral.copyLink": "Kopiuj link",
|
||||
"workspace.referral.copied": "Skopiowano",
|
||||
"workspace.referral.overview.title": "Zaproś znajomych",
|
||||
"workspace.referral.overview.subtitle": "Zdobądź $5, gdy znajomy się zasubskrybuje. On też dostanie $5.",
|
||||
"workspace.referral.instructions.share": "Udostępnij swój link polecający",
|
||||
"workspace.referral.instructions.subscribe": "Twój znajomy dołącza i subskrybuje Go",
|
||||
"workspace.referral.instructions.claim": "Oboje otrzymujecie kredyt $5 do wykorzystania na limity użycia Go",
|
||||
"workspace.referral.rewards.title": "Nagrody za polecenia",
|
||||
"workspace.referral.rewards.description": "Wykorzystaj dostępne środki za polecenia na swoje użycie Go.",
|
||||
"workspace.referral.rewards.subtitle": "Wykorzystano {{applied}} / {{total}} nagród.",
|
||||
"workspace.referral.rewards.empty": "Brak nagród za polecenia.",
|
||||
"workspace.referral.table.reward": "Nagroda",
|
||||
"workspace.referral.table.referral": "Opis",
|
||||
"workspace.referral.table.date": "Data",
|
||||
"workspace.referral.reward.description.inviter": "Zaproszono {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Zaproszony przez {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Subskrybuj, aby odblokować",
|
||||
"workspace.referral.reward.action.view": "Zobacz nagrodę",
|
||||
"workspace.referral.reward.action.applied": "Nagroda wykorzystana",
|
||||
"workspace.referral.reward.source.pendingInviter": "Oczekiwanie na jego subskrypcję",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Subskrybuj, aby odblokować nagrodę",
|
||||
"workspace.referral.reward.source.available": "Nagroda gotowa do wykorzystania",
|
||||
"workspace.referral.reward.source.applied": "Nagroda wykorzystana",
|
||||
"workspace.referral.reward.status.applied": "Nagroda wykorzystana",
|
||||
"workspace.referral.reward.status.pendingInviter": "Subskrybuj, aby odblokować",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Subskrybuj, aby odblokować",
|
||||
"workspace.referral.apply.noGo": "Subskrybuj, aby odblokować",
|
||||
"workspace.referral.apply.preview": "Zobacz nagrodę",
|
||||
"workspace.referral.apply.action": "Wykorzystaj",
|
||||
"workspace.referral.apply.confirmTitle": "Wykorzystaj nagrodę",
|
||||
"workspace.referral.apply.confirmBody": "Wykorzystaj {{amount}}, aby zmniejszyć aktualne użycie w tym workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Wykorzystaj",
|
||||
|
||||
"download.title": "OpenCode | Pobierz",
|
||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||
"download.hero.title": "Pobierz OpenCode",
|
||||
|
||||
@@ -674,6 +674,41 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Другие способы оплаты",
|
||||
"workspace.lite.promo.selectMethod": "Выберите способ оплаты",
|
||||
|
||||
"workspace.referral.copyLink": "Копировать ссылку",
|
||||
"workspace.referral.copied": "Скопировано",
|
||||
"workspace.referral.overview.title": "Пригласите друзей",
|
||||
"workspace.referral.overview.subtitle": "Получите $5, когда друг оформит подписку. Он тоже получит $5.",
|
||||
"workspace.referral.instructions.share": "Поделитесь своей реферальной ссылкой",
|
||||
"workspace.referral.instructions.subscribe": "Ваш друг присоединяется и оформляет подписку на Go",
|
||||
"workspace.referral.instructions.claim":
|
||||
"Вы оба получаете кредит на использование $5, который можно применить к лимитам использования Go",
|
||||
"workspace.referral.rewards.title": "Реферальные награды",
|
||||
"workspace.referral.rewards.description": "Используйте доступные реферальные кредиты для оплаты использования Go.",
|
||||
"workspace.referral.rewards.subtitle": "Использовано {{applied}} / {{total}} наград.",
|
||||
"workspace.referral.rewards.empty": "Реферальных наград пока нет.",
|
||||
"workspace.referral.table.reward": "Награда",
|
||||
"workspace.referral.table.referral": "Описание",
|
||||
"workspace.referral.table.date": "Дата",
|
||||
"workspace.referral.reward.description.inviter": "Приглашён {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "Приглашены пользователем {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Оформите подписку для разблокировки",
|
||||
"workspace.referral.reward.action.view": "Посмотреть награду",
|
||||
"workspace.referral.reward.action.applied": "Награда использована",
|
||||
"workspace.referral.reward.source.pendingInviter": "Ожидание его подписки",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Подпишитесь, чтобы разблокировать награду",
|
||||
"workspace.referral.reward.source.available": "Награда готова к применению",
|
||||
"workspace.referral.reward.source.applied": "Награда использована",
|
||||
"workspace.referral.reward.status.applied": "Награда использована",
|
||||
"workspace.referral.reward.status.pendingInviter": "Оформите подписку для разблокировки",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Оформите подписку для разблокировки",
|
||||
"workspace.referral.apply.noGo": "Оформите подписку для разблокировки",
|
||||
"workspace.referral.apply.preview": "Посмотреть награду",
|
||||
"workspace.referral.apply.action": "Применить",
|
||||
"workspace.referral.apply.confirmTitle": "Применить награду",
|
||||
"workspace.referral.apply.confirmBody":
|
||||
"Используйте {{amount}}, чтобы уменьшить текущее использование этого workspace.",
|
||||
"workspace.referral.apply.confirmAction": "Применить",
|
||||
|
||||
"download.title": "OpenCode | Скачать",
|
||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||
"download.hero.title": "Скачать OpenCode",
|
||||
|
||||
@@ -663,6 +663,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
|
||||
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
|
||||
|
||||
"workspace.referral.copyLink": "คัดลอกลิงก์",
|
||||
"workspace.referral.copied": "คัดลอกแล้ว",
|
||||
"workspace.referral.overview.title": "ชวนเพื่อน",
|
||||
"workspace.referral.overview.subtitle": "รับ $5 เมื่อเพื่อนสมัครสมาชิก เพื่อนก็จะได้รับ $5 เช่นกัน",
|
||||
"workspace.referral.instructions.share": "แชร์ลิงก์แนะนำของคุณ",
|
||||
"workspace.referral.instructions.subscribe": "เพื่อนของคุณเข้าร่วมและสมัครสมาชิก Go",
|
||||
"workspace.referral.instructions.claim": "คุณทั้งคู่จะได้รับเครดิตการใช้งาน $5 เพื่อใช้กับขีดจำกัดการใช้งาน Go",
|
||||
"workspace.referral.rewards.title": "รางวัลการแนะนำ",
|
||||
"workspace.referral.rewards.description": "ใช้เครดิตการแนะนำที่มีอยู่กับการใช้งาน Go ของคุณ",
|
||||
"workspace.referral.rewards.subtitle": "ใช้แล้ว {{applied}} / {{total}} รางวัล",
|
||||
"workspace.referral.rewards.empty": "ยังไม่มีรางวัลการแนะนำ",
|
||||
"workspace.referral.table.reward": "รางวัล",
|
||||
"workspace.referral.table.referral": "คำอธิบาย",
|
||||
"workspace.referral.table.date": "วันที่",
|
||||
"workspace.referral.reward.description.inviter": "เชิญ {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "ได้รับเชิญจาก {{email}}",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "สมัครสมาชิกเพื่อปลดล็อก",
|
||||
"workspace.referral.reward.action.view": "ดูรางวัล",
|
||||
"workspace.referral.reward.action.applied": "ใช้รางวัลแล้ว",
|
||||
"workspace.referral.reward.source.pendingInviter": "รอเพื่อนสมัครสมาชิก",
|
||||
"workspace.referral.reward.source.pendingInvitee": "สมัครสมาชิกเพื่อปลดล็อกรางวัล",
|
||||
"workspace.referral.reward.source.available": "รางวัลพร้อมใช้งาน",
|
||||
"workspace.referral.reward.source.applied": "ใช้รางวัลแล้ว",
|
||||
"workspace.referral.reward.status.applied": "ใช้รางวัลแล้ว",
|
||||
"workspace.referral.reward.status.pendingInviter": "สมัครสมาชิกเพื่อปลดล็อก",
|
||||
"workspace.referral.reward.status.pendingInvitee": "สมัครสมาชิกเพื่อปลดล็อก",
|
||||
"workspace.referral.apply.noGo": "สมัครสมาชิกเพื่อปลดล็อก",
|
||||
"workspace.referral.apply.preview": "ดูรางวัล",
|
||||
"workspace.referral.apply.action": "ใช้",
|
||||
"workspace.referral.apply.confirmTitle": "ใช้รางวัล",
|
||||
"workspace.referral.apply.confirmBody": "ใช้ {{amount}} เพื่อลดการใช้งานปัจจุบันของ workspace นี้",
|
||||
"workspace.referral.apply.confirmAction": "ใช้",
|
||||
|
||||
"download.title": "OpenCode | ดาวน์โหลด",
|
||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||
"download.hero.title": "ดาวน์โหลด OpenCode",
|
||||
|
||||
@@ -670,6 +670,40 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
|
||||
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
|
||||
|
||||
"workspace.referral.copyLink": "Bağlantıyı Kopyala",
|
||||
"workspace.referral.copied": "Kopyalandı",
|
||||
"workspace.referral.overview.title": "Arkadaşlarını davet et",
|
||||
"workspace.referral.overview.subtitle": "Bir arkadaşın abone olduğunda $5 kazan. O da $5 alacak.",
|
||||
"workspace.referral.instructions.share": "Referans bağlantını paylaş",
|
||||
"workspace.referral.instructions.subscribe": "Arkadaşın katılır ve Go'ya abone olur",
|
||||
"workspace.referral.instructions.claim":
|
||||
"İkiniz de Go kullanım limitlerinize uygulamak için $5 kullanım kredisi alırsınız",
|
||||
"workspace.referral.rewards.title": "Davet ödülleri",
|
||||
"workspace.referral.rewards.description": "Mevcut davet kredilerini Go kullanımınıza uygulayın.",
|
||||
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} ödül kullanıldı.",
|
||||
"workspace.referral.rewards.empty": "Henüz davet ödülü yok.",
|
||||
"workspace.referral.table.reward": "Ödül",
|
||||
"workspace.referral.table.referral": "Açıklama",
|
||||
"workspace.referral.table.date": "Tarih",
|
||||
"workspace.referral.reward.description.inviter": "{{email}} davet edildi",
|
||||
"workspace.referral.reward.description.invitee": "{{email}} tarafından davet edildi",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "Kilidi açmak için abone ol",
|
||||
"workspace.referral.reward.action.view": "Ödülü Görüntüle",
|
||||
"workspace.referral.reward.action.applied": "Ödül Kullanıldı",
|
||||
"workspace.referral.reward.source.pendingInviter": "Abone olması bekleniyor",
|
||||
"workspace.referral.reward.source.pendingInvitee": "Ödülün kilidini açmak için abone ol",
|
||||
"workspace.referral.reward.source.available": "Ödül kullanıma hazır",
|
||||
"workspace.referral.reward.source.applied": "Ödül kullanıldı",
|
||||
"workspace.referral.reward.status.applied": "Ödül Kullanıldı",
|
||||
"workspace.referral.reward.status.pendingInviter": "Kilidi açmak için abone ol",
|
||||
"workspace.referral.reward.status.pendingInvitee": "Kilidi açmak için abone ol",
|
||||
"workspace.referral.apply.noGo": "Kilidi açmak için abone ol",
|
||||
"workspace.referral.apply.preview": "Ödülü Görüntüle",
|
||||
"workspace.referral.apply.action": "Kullan",
|
||||
"workspace.referral.apply.confirmTitle": "Ödülü kullan",
|
||||
"workspace.referral.apply.confirmBody": "Bu workspace'in mevcut kullanımını azaltmak için {{amount}} kullan.",
|
||||
"workspace.referral.apply.confirmAction": "Kullan",
|
||||
|
||||
"download.title": "OpenCode | İndir",
|
||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||
"download.hero.title": "OpenCode'u İndir",
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
import { dict as en } from "./en"
|
||||
|
||||
export const dict = {
|
||||
...en,
|
||||
"nav.github": "GitHub",
|
||||
"nav.docs": "Документація",
|
||||
"nav.changelog": "Журнал змін",
|
||||
"nav.discord": "Discord",
|
||||
"nav.x": "X",
|
||||
"nav.enterprise": "Enterprise",
|
||||
"nav.zen": "Zen",
|
||||
"nav.login": "Увійти",
|
||||
"nav.free": "Завантажити",
|
||||
"nav.home": "Головна",
|
||||
"nav.openMenu": "Відкрити меню",
|
||||
"nav.getStartedFree": "Почати безкоштовно",
|
||||
"nav.logoAlt": "OpenCode",
|
||||
|
||||
"nav.context.copyLogo": "Копіювати логотип як SVG",
|
||||
"nav.context.copyWordmark": "Копіювати знак як SVG",
|
||||
"nav.context.brandAssets": "Бренд-матеріали",
|
||||
|
||||
"footer.github": "GitHub",
|
||||
"footer.docs": "Документація",
|
||||
"footer.changelog": "Журнал змін",
|
||||
"footer.discord": "Discord",
|
||||
"footer.x": "X",
|
||||
|
||||
"legal.brand": "Бренд",
|
||||
"legal.privacy": "Конфіденційність",
|
||||
"legal.terms": "Умови",
|
||||
|
||||
"email.title": "Дізнайтеся першими про нові продукти",
|
||||
"email.subtitle": "Приєднуйтесь до списку очікування для раннього доступу.",
|
||||
"email.placeholder": "Електронна адреса",
|
||||
"email.subscribe": "Підписатися",
|
||||
"email.success": "Майже готово! Перевірте пошту та підтвердьте адресу",
|
||||
|
||||
"notFound.title": "Не знайдено | opencode",
|
||||
"notFound.heading": "404 — Сторінку не знайдено",
|
||||
"notFound.home": "Головна",
|
||||
"notFound.docs": "Документація",
|
||||
"notFound.github": "GitHub",
|
||||
"notFound.discord": "Discord",
|
||||
"notFound.logoLightAlt": "світлий логотип opencode",
|
||||
"notFound.logoDarkAlt": "темний логотип opencode",
|
||||
|
||||
"user.logout": "Вийти",
|
||||
|
||||
"auth.callback.error.codeMissing": "Не знайдено код авторизації.",
|
||||
|
||||
"workspace.select": "Виберіть робочий простір",
|
||||
"workspace.createNew": "+ Створити новий робочий простір",
|
||||
"workspace.modal.title": "Створити новий робочий простір",
|
||||
"workspace.modal.placeholder": "Введіть назву робочого простору",
|
||||
|
||||
"common.cancel": "Скасувати",
|
||||
"common.creating": "Створення...",
|
||||
"common.create": "Створити",
|
||||
"common.contactUs": "Зв'яжіться з нами",
|
||||
|
||||
"common.videoUnsupported": "Ваш браузер не підтримує відео.",
|
||||
"common.figure": "Рис. {{n}}.",
|
||||
"common.faq": "FAQ",
|
||||
"common.learnMore": "Дізнатися більше",
|
||||
|
||||
"error.invalidPlan": "Недійсний план",
|
||||
"error.workspaceRequired": "ID робочого простору обов'язкове",
|
||||
"error.alreadySubscribed": "Цей робочий простір уже має підписку",
|
||||
"error.limitRequired": "Ліміт обов'язковий.",
|
||||
"error.monthlyLimitInvalid": "Встановіть дійсний місячний ліміт.",
|
||||
"error.workspaceNameRequired": "Назва робочого простору обов'язкова.",
|
||||
"error.nameTooLong": "Назва має містити не більше 255 символів.",
|
||||
"error.emailRequired": "Електронна адреса обов'язкова",
|
||||
"error.roleRequired": "Роль обов'язкова",
|
||||
"error.idRequired": "ID обов'язкове",
|
||||
"error.nameRequired": "Назва обов'язкова",
|
||||
"error.providerRequired": "Провайдер обов'язковий",
|
||||
"error.apiKeyRequired": "Ключ API обов'язковий",
|
||||
"error.modelRequired": "Модель обов'язкова",
|
||||
"error.reloadAmountMin": "Сума поповнення має бути щонайменше ${{amount}}",
|
||||
"error.reloadTriggerMin": "Поріг балансу має бути щонайменше ${{amount}}",
|
||||
|
||||
"app.meta.description": "OpenCode — відкритий агент для програмування.",
|
||||
|
||||
"home.title": "OpenCode | Відкритий AI-агент для кодування",
|
||||
|
||||
"temp.title": "opencode | AI-агент для кодування, створений для термінала",
|
||||
"temp.hero.title": "AI-агент для кодування, створений для термінала",
|
||||
"temp.zen": "opencode zen",
|
||||
"temp.getStarted": "Почати",
|
||||
"temp.feature.native.title": "Рідний TUI",
|
||||
"temp.feature.native.body": "Чуйний, рідний інтерфейс термінала з темами",
|
||||
"temp.feature.zen.beforeLink": "A",
|
||||
"temp.feature.zen.link": "добірка моделей",
|
||||
"temp.feature.zen.afterLink": "від opencode",
|
||||
"temp.feature.models.beforeLink": "Підтримує 75+ LLM-провайдерів через",
|
||||
"temp.feature.models.afterLink": ", включаючи локальні моделі",
|
||||
"temp.screenshot.caption": "OpenCode TUI з темою tokyonight",
|
||||
"temp.screenshot.alt": "OpenCode TUI з темою tokyonight",
|
||||
"temp.logoLightAlt": "світлий логотип opencode",
|
||||
"temp.logoDarkAlt": "темний логотип opencode",
|
||||
|
||||
"home.banner.badge": "Нове",
|
||||
"home.banner.text": "Десктопний застосунок доступний у бета-версії",
|
||||
"home.banner.platforms": "на macOS, Windows та Linux",
|
||||
"home.banner.downloadNow": "Завантажити зараз",
|
||||
"home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку",
|
||||
|
||||
"home.hero.title": "Відкритий AI-агент для кодування",
|
||||
"home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,",
|
||||
"home.hero.subtitle.b": "включно з Claude, GPT, Gemini та іншими.",
|
||||
|
||||
"home.install.ariaLabel": "Параметри встановлення",
|
||||
|
||||
"home.what.title": "Що таке OpenCode?",
|
||||
"home.what.body": "OpenCode — це відкритий агент, який допомагає писати код у терміналі, IDE або на десктопі.",
|
||||
"home.what.lsp.title": "LSP увімкнено",
|
||||
"home.what.lsp.body": "Автоматично завантажує потрібні LSP для LLM",
|
||||
"home.what.multiSession.title": "Багатосесійність",
|
||||
"home.what.multiSession.body": "Запускайте кількох агентів паралельно в одному проекті",
|
||||
"home.what.shareLinks.title": "Посилання для обміну",
|
||||
"home.what.shareLinks.body": "Діліться посиланням на будь-яку сесію для обговорення або налагодження",
|
||||
"home.what.copilot.title": "GitHub Copilot",
|
||||
"home.what.copilot.body": "Увійдіть через GitHub, щоб використовувати свій обліковий запис Copilot",
|
||||
"home.what.chatgptPlus.title": "ChatGPT Plus/Pro",
|
||||
"home.what.chatgptPlus.body": "Увійдіть через OpenAI, щоб використовувати ChatGPT Plus або Pro",
|
||||
"home.what.anyModel.title": "Будь-яка модель",
|
||||
"home.what.anyModel.body": "75+ LLM-провайдерів через Models.dev, включаючи локальні моделі",
|
||||
"home.what.anyEditor.title": "Будь-який редактор",
|
||||
"home.what.anyEditor.body": "Доступний як термінальний інтерфейс, десктопний застосунок та розширення IDE",
|
||||
"home.what.readDocs": "Читати документацію",
|
||||
|
||||
"home.growth.title": "Відкритий AI-агент для кодування",
|
||||
"home.growth.body":
|
||||
"З понад <strong>{{stars}}</strong> зірками на GitHub, <strong>{{contributors}}</strong> учасниками та понад <strong>{{commits}}</strong> комітами, OpenCode використовують понад <strong>{{monthlyUsers}}</strong> розробників щомісяця.",
|
||||
"home.growth.githubStars": "Зірки GitHub",
|
||||
"home.growth.contributors": "Учасники",
|
||||
"home.growth.monthlyDevs": "Розробників на місяць",
|
||||
|
||||
"home.privacy.title": "Створено для конфіденційності",
|
||||
"home.privacy.body":
|
||||
"OpenCode не зберігає ваш код або контекстні дані, тому може працювати в середовищах з чутливими даними.",
|
||||
"home.privacy.learnMore": "Дізнатися більше про",
|
||||
"home.privacy.link": "конфіденційність",
|
||||
|
||||
"home.faq.q1": "Що таке OpenCode?",
|
||||
"home.faq.a1":
|
||||
"OpenCode — це відкритий агент, який допомагає писати та запускати код з будь-якою AI-моделлю. Доступний як термінальний інтерфейс, десктопний застосунок або розширення IDE.",
|
||||
"home.faq.q2": "Як почати користуватися OpenCode?",
|
||||
"home.faq.a2.before": "Найпростіший спосіб почати — прочитати",
|
||||
"home.faq.a2.link": "вступ",
|
||||
"home.faq.q3": "Чи потрібні додаткові AI-підписки для використання OpenCode?",
|
||||
"home.faq.a3.p1":
|
||||
"Не обов'язково, OpenCode має набір безкоштовних моделей, які можна використовувати без реєстрації.",
|
||||
"home.faq.a3.p2.beforeZen":
|
||||
"Крім цього, ви можете використовувати будь-які популярні моделі, створивши обліковий запис",
|
||||
"home.faq.a3.p2.afterZen": ".",
|
||||
"home.faq.a3.p3":
|
||||
"Хоча ми рекомендуємо Zen, OpenCode також працює з усіма популярними провайдерами, такими як OpenAI, Anthropic, xAI тощо.",
|
||||
"home.faq.a3.p4.beforeLocal": "Ви навіть можете підключити свої",
|
||||
"home.faq.a3.p4.localLink": "локальні моделі",
|
||||
"home.faq.q4": "Чи можу я використовувати свої наявні AI-підписки з OpenCode?",
|
||||
"home.faq.a4.p1":
|
||||
"Так, OpenCode підтримує підписки всіх основних провайдерів. Ви можете використовувати Claude Pro/Max, ChatGPT Plus/Pro або GitHub Copilot.",
|
||||
"home.faq.q5": "Чи можна використовувати OpenCode лише в терміналі?",
|
||||
"home.faq.a5.beforeDesktop": "Вже ні! OpenCode тепер доступний як застосунок для",
|
||||
"home.faq.a5.desktop": "десктопа",
|
||||
"home.faq.a5.and": "та",
|
||||
"home.faq.a5.web": "вебу",
|
||||
"home.faq.q6": "Скільки коштує OpenCode?",
|
||||
"home.faq.a6":
|
||||
"OpenCode є 100% безкоштовним. Він також має набір безкоштовних моделей. Додаткові витрати можливі, якщо ви підключите іншого провайдера.",
|
||||
"home.faq.q7": "А як щодо даних та конфіденційності?",
|
||||
"home.faq.a7.p1":
|
||||
"Ваші дані зберігаються лише тоді, коли ви використовуєте безкоштовні моделі або створюєте посилання для обміну.",
|
||||
"home.faq.a7.p2.beforeModels": "Дізнайтеся більше про",
|
||||
"home.faq.a7.p2.modelsLink": "наші моделі",
|
||||
"home.faq.a7.p2.and": "та",
|
||||
"home.faq.a7.p2.shareLink": "сторінки обміну",
|
||||
"home.faq.q8": "Чи є OpenCode відкритим?",
|
||||
"home.faq.a8.p1": "Так, OpenCode повністю відкритий. Вихідний код доступний публічно на",
|
||||
"home.faq.a8.p2": "під ліцензією",
|
||||
"home.faq.a8.mitLicense": "MIT License",
|
||||
"home.faq.a8.p3":
|
||||
", тобто кожен може використовувати, змінювати або сприяти його розвитку. Будь-хто зі спільноти може створювати issues, надсилати pull request'и та розширювати функціональність.",
|
||||
|
||||
"home.zenCta.title": "Отримайте доступ до надійних оптимізованих моделей для агентів кодування",
|
||||
"home.zenCta.body":
|
||||
"Zen дає доступ до добірки AI-моделей, які OpenCode протестував спеціально для агентів кодування. Не турбуйтеся про нестабільну якість — використовуйте перевірені моделі.",
|
||||
"home.zenCta.link": "Дізнатися про Zen",
|
||||
|
||||
"zen.title": "OpenCode Zen | Добірка надійних оптимізованих моделей для агентів кодування",
|
||||
"zen.hero.title": "Надійні оптимізовані моделі для агентів кодування",
|
||||
"zen.hero.body":
|
||||
"Zen дає доступ до добірки AI-моделей, які OpenCode протестував спеціально для агентів кодування. Не турбуйтеся про нестабільну якість — використовуйте перевірені моделі.",
|
||||
|
||||
"zen.faq.q1": "Що таке OpenCode Zen?",
|
||||
"zen.faq.a1": "Zen — це добірка AI-моделей, протестованих для агентів кодування, створена командою OpenCode.",
|
||||
"zen.faq.q2": "Чому Zen точніший?",
|
||||
"zen.faq.a2":
|
||||
"Zen надає лише моделі, спеціально протестовані для агентів кодування. Ви ж не використовуєте масло ніж для стейка — не використовуйте погані моделі для кодування.",
|
||||
"zen.faq.q3": "Чи Zen дешевший?",
|
||||
"zen.faq.a3":
|
||||
"Zen не є прибутковим. Zen передає вам вартість від провайдерів моделей. Чим вище використання Zen, тим кращі ціни OpenCode може узгодити та передати вам.",
|
||||
"zen.faq.q4": "Скільки коштує Zen?",
|
||||
"zen.faq.a4.p1.beforePricing": "Zen",
|
||||
"zen.faq.a4.p1.pricingLink": "стягує плату за запит",
|
||||
"zen.faq.a4.p1.afterPricing": "без націнок — ви платите рівно стільки, скільки стягує провайдер моделі.",
|
||||
"zen.faq.a4.p2.beforeAccount": "Загальна вартість залежить від використання. Ви можете встановити місячні ліміти в",
|
||||
"zen.faq.a4.p2.accountLink": "обліковому записі",
|
||||
"zen.faq.a4.p3":
|
||||
"Щоб покрити витрати, OpenCode додає лише невелику комісію за обробку платежу в розмірі $1.23 за кожне поповнення балансу $20.",
|
||||
"zen.faq.q5": "А як щодо даних та конфіденційності?",
|
||||
"zen.faq.a5.beforeExceptions":
|
||||
"Усі моделі Zen розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
|
||||
"zen.faq.a5.exceptionsLink": "такими винятками",
|
||||
"zen.faq.q6": "Чи можна встановити ліміти витрат?",
|
||||
"zen.faq.a6": "Так, ви можете встановити місячні ліміти витрат в обліковому записі.",
|
||||
"zen.faq.q7": "Чи можна скасувати?",
|
||||
"zen.faq.a7": "Так, ви можете вимкнути оплату в будь-який час і використовувати залишок.",
|
||||
"zen.faq.q8": "Чи можна використовувати Zen з іншими агентами кодування?",
|
||||
"zen.faq.a8":
|
||||
"Хоча Zen чудово працює з OpenCode, ви можете використовувати Zen з будь-яким агентом. Дотримуйтесь інструкцій з налаштування у вашому агенті.",
|
||||
|
||||
"zen.cta.start": "Почати з Zen",
|
||||
"zen.pricing.title": "Додати $20 балансу Pay as you go",
|
||||
"zen.pricing.fee": "(+$1.23 комісія за обробку карти)",
|
||||
"zen.pricing.body": "Використовуйте з будь-яким агентом. Встановлюйте місячні ліміти. Скасуйте в будь-який час.",
|
||||
"zen.problem.title": "Яку проблему вирішує Zen?",
|
||||
"zen.problem.body":
|
||||
"Доступно багато моделей, але лише деякі добре працюють з агентами кодування. Більшість провайдерів налаштовують їх по-різному з різними результатами.",
|
||||
"zen.problem.subtitle": "Ми вирішуємо це для всіх, а не лише для користувачів OpenCode.",
|
||||
"zen.problem.item1": "Тестування вибраних моделей та консультації з їхніми командами",
|
||||
"zen.problem.item2": "Співпраця з провайдерами для забезпечення правильної доставки",
|
||||
"zen.problem.item3": "Бенчмаркінг усіх комбінацій моделей та провайдерів, які ми рекомендуємо",
|
||||
"zen.how.title": "Як працює Zen",
|
||||
"zen.how.body":
|
||||
"Хоча ми пропонуємо використовувати Zen з OpenCode, ви можете використовувати Zen з будь-яким агентом.",
|
||||
"zen.how.step1.title": "Зареєструйтеся та додайте $20 балансу",
|
||||
"zen.how.step1.beforeLink": "дотримуйтесь",
|
||||
"zen.how.step1.link": "інструкцій з налаштування",
|
||||
"zen.how.step2.title": "Використовуйте Zen із прозорими цінами",
|
||||
"zen.how.step2.link": "платіть за запит",
|
||||
"zen.how.step2.afterLink": "без націнок",
|
||||
"zen.how.step3.title": "Автоматичне поповнення",
|
||||
"zen.how.step3.body": "коли баланс досягає $5, ми автоматично додаємо $20",
|
||||
"zen.privacy.title": "Ваша конфіденційність важлива для нас",
|
||||
"zen.privacy.beforeExceptions":
|
||||
"Усі моделі Zen розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
|
||||
"zen.privacy.exceptionsLink": "такими винятками",
|
||||
|
||||
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
|
||||
"go.meta.description":
|
||||
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.",
|
||||
"go.hero.title": "Недорогі моделі кодування для всіх",
|
||||
"go.hero.body":
|
||||
"Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
|
||||
|
||||
"go.cta.start": "Підписатися на Go",
|
||||
"go.cta.template": "{{text}} {{price}}",
|
||||
"go.cta.text": "Підписатися на Go",
|
||||
"go.cta.price": "$10/місяць",
|
||||
"go.cta.promo": "$5 перший місяць",
|
||||
"go.pricing.body":
|
||||
"Використовуйте з будь-яким агентом. $5 перший місяць, потім $10/місяць. Поповнюйте за потреби. Скасуйте в будь-який час.",
|
||||
"go.graph.free": "Безкоштовно",
|
||||
"go.graph.freePill": "Big Pickle та безкоштовні моделі",
|
||||
"go.graph.go": "Go",
|
||||
"go.graph.label": "Запитів за 5 годин",
|
||||
"go.graph.usageLimits": "Ліміти використання",
|
||||
"go.graph.tick": "{{n}}x",
|
||||
"go.graph.aria": "Запитів за 5 год: {{free}} vs {{go}}",
|
||||
|
||||
"go.testimonials.brand.zen": "Zen",
|
||||
"go.testimonials.brand.go": "Go",
|
||||
"go.testimonials.handle": "@OpenCode",
|
||||
"go.testimonials.dax.name": "Dax Raad",
|
||||
"go.testimonials.dax.title": "ex-CEO, Terminal Products",
|
||||
"go.testimonials.dax.quoteAfter": "змінило моє життя, це справді очевидний вибір.",
|
||||
"go.testimonials.jay.name": "Jay V",
|
||||
"go.testimonials.jay.title": "ex-Founder, SEED, PM, Melt, Pop, Dapt, Cadmus, and ViewPoint",
|
||||
"go.testimonials.jay.quoteBefore": "4 з 5 людей у нашій команді люблять використовувати",
|
||||
"go.testimonials.jay.quoteAfter": ".",
|
||||
"go.testimonials.adam.name": "Adam Elmore",
|
||||
"go.testimonials.adam.title": "ex-Hero, AWS",
|
||||
"go.testimonials.adam.quoteBefore": "Я не можу достатньо рекомендувати",
|
||||
"go.testimonials.adam.quoteAfter": ". Серйозно, це дійсно добре.",
|
||||
"go.testimonials.david.name": "David Hill",
|
||||
"go.testimonials.david.title": "ex-Head of Design, Laravel",
|
||||
"go.testimonials.david.quoteBefore": "Завдяки",
|
||||
"go.testimonials.david.quoteAfter": "я знаю, що всі моделі протестовані та ідеальні для агентів кодування.",
|
||||
"go.testimonials.frank.name": "Frank Wang",
|
||||
"go.testimonials.frank.title": "ex-Intern, Nvidia (4 times)",
|
||||
"go.testimonials.frank.quote": "Хотів би я досі бути в Nvidia.",
|
||||
"go.problem.title": "Яку проблему вирішує Go?",
|
||||
"go.problem.body":
|
||||
"Ми зосереджені на тому, щоб зробити досвід OpenCode доступним для якомога більшої кількості людей. OpenCode Go — це недорога підписка: $5 за перший місяць, потім $10/місяць. Вона надає щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
|
||||
"go.problem.subtitle": " ",
|
||||
"go.problem.item1": "Недорога підписка",
|
||||
"go.problem.item2": "Щедрі ліміти та надійний доступ",
|
||||
"go.problem.item3": "Створено для якомога більшої кількості програмістів",
|
||||
"go.problem.item4":
|
||||
"Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash",
|
||||
"go.how.title": "Як працює Go",
|
||||
"go.how.body":
|
||||
"Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.",
|
||||
"go.how.step1.title": "Створіть обліковий запис",
|
||||
"go.how.step1.beforeLink": "дотримуйтесь",
|
||||
"go.how.step1.link": "інструкцій з налаштування",
|
||||
"go.how.step2.title": "Підпишіться на Go",
|
||||
"go.how.step2.link": "$5 перший місяць",
|
||||
"go.how.step2.afterLink": "потім $10/місяць із щедрими лімітами",
|
||||
"go.how.step3.title": "Почніть кодувати",
|
||||
"go.how.step3.body": "з надійним доступом до моделей з відкритим кодом",
|
||||
"go.privacy.title": "Ваша конфіденційність важлива для нас",
|
||||
"go.privacy.body":
|
||||
"План розроблений переважно для міжнародних користувачів, з моделями, розміщеними в США, ЄС та Сінгапурі для стабільного глобального доступу.",
|
||||
"go.privacy.contactAfter": "якщо у вас є запитання.",
|
||||
"go.privacy.beforeExceptions":
|
||||
"Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
|
||||
"go.privacy.exceptionsLink": "такими винятками",
|
||||
"go.faq.q1": "Що таке OpenCode Go?",
|
||||
"go.faq.a1":
|
||||
"Go — це недорога підписка, яка надає надійний доступ до найкращих моделей з відкритим кодом для агентного кодування.",
|
||||
"go.faq.q2": "Які моделі включає Go?",
|
||||
"go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.",
|
||||
"go.faq.q3": "Чи Go те саме, що Zen?",
|
||||
"go.faq.a3":
|
||||
"Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом.",
|
||||
"go.faq.q4": "Скільки коштує Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go коштує",
|
||||
"go.faq.a4.p1.pricingLink": "$5 за перший місяць",
|
||||
"go.faq.a4.p1.afterPricing": "потім $10/місяць із щедрими лімітами.",
|
||||
"go.faq.a4.p2.beforeAccount": "Ви можете керувати підпискою в",
|
||||
"go.faq.a4.p2.accountLink": "обліковому записі",
|
||||
"go.faq.a4.p3": "Скасуйте в будь-який час.",
|
||||
"go.faq.q5": "А як щодо даних та конфіденційності?",
|
||||
"go.faq.a5.body":
|
||||
"План розроблений переважно для міжнародних користувачів, з моделями в США, ЄС та Сінгапурі. Провайдери дотримуються політики нульового зберігання.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
|
||||
"go.faq.a5.exceptionsLink": "такими винятками",
|
||||
"go.faq.q6": "Чи можна поповнити баланс?",
|
||||
"go.faq.a6": "Якщо вам потрібно більше використання, ви можете поповнити баланс в обліковому записі.",
|
||||
"go.faq.q7": "Чи можна скасувати?",
|
||||
"go.faq.a7": "Так, ви можете скасувати в будь-який час.",
|
||||
"go.faq.q8": "Чи можна використовувати Go з іншими агентами кодування?",
|
||||
"go.faq.a8": "Так, ви можете використовувати Go з будь-яким агентом.",
|
||||
|
||||
"go.faq.q9": "Яка різниця між безкоштовними моделями та Go?",
|
||||
"go.faq.a9":
|
||||
"Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.",
|
||||
"zen.api.error.modelNotSupported": "Модель {{model}} не підтримується",
|
||||
"zen.api.error.modelFormatNotSupported": "Модель {{model}} не підтримується для формату {{format}}",
|
||||
"zen.api.error.noProviderAvailable": "Немає доступного провайдера",
|
||||
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується",
|
||||
"zen.api.error.missingApiKey": "Відсутній ключ API.",
|
||||
"zen.api.error.invalidApiKey": "Недійсний ключ API.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Досягнуто тижневого ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Досягнуто місячного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Немає способу оплати. Додайте метод оплати: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Недостатньо коштів. Керуйте оплатою: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
"Ваш робочий простір досяг місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Модель вимкнено",
|
||||
"zen.api.error.trialEnded":
|
||||
"Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Доступ до найкращих моделей кодування",
|
||||
"black.meta.description": "Отримайте доступ до Claude, GPT, Gemini та інших із планами підписки OpenCode Black.",
|
||||
"black.hero.title": "Доступ до найкращих моделей кодування",
|
||||
"black.hero.subtitle": "Включаючи Claude, GPT, Gemini та інші",
|
||||
"black.title": "OpenCode Black | Ціни",
|
||||
"black.paused": "Реєстрація в план Black тимчасово призупинена.",
|
||||
"black.plan.icon20": "План Black 20",
|
||||
"black.plan.icon100": "План Black 100",
|
||||
"black.plan.icon200": "План Black 200",
|
||||
"black.plan.multiplier100": "5x більше використання ніж Black 20",
|
||||
"black.plan.multiplier200": "20x більше використання ніж Black 20",
|
||||
"black.price.perMonth": "на місяць",
|
||||
"black.price.perPersonBilledMonthly": "за особу з щомісячною оплатою",
|
||||
"black.terms.1": "Ваша підписка не розпочнеться негайно",
|
||||
"black.terms.2": "Вас додадуть до списку очікування та активують незабаром",
|
||||
"black.terms.3": "Картку буде списано лише після активації підписки",
|
||||
"black.terms.4": "Діють ліміти використання, інтенсивне автоматичне використання може швидше вичерпати ліміти",
|
||||
"black.terms.5": "Підписки призначені для фізичних осіб, зверніться в Enterprise для команд",
|
||||
"black.terms.6": "Ліміти можуть бути змінені, а плани можуть бути припинені в майбутньому",
|
||||
"black.terms.7": "Скасуйте підписку в будь-який час",
|
||||
"black.action.continue": "Продовжити",
|
||||
"black.finePrint.beforeTerms": "Зазначені ціни не включають податки",
|
||||
"black.finePrint.terms": "Умови надання послуг",
|
||||
"black.workspace.title": "OpenCode Black | Виберіть робочий простір",
|
||||
"black.workspace.selectPlan": "Виберіть робочий простір для цього плану",
|
||||
"black.workspace.name": "Робочий простір {{n}}",
|
||||
"black.subscribe.title": "Підписатися на OpenCode Black",
|
||||
"black.subscribe.paymentMethod": "Спосіб оплати",
|
||||
"black.subscribe.loadingPaymentForm": "Завантаження форми оплати...",
|
||||
"black.subscribe.selectWorkspaceToContinue": "Виберіть робочий простір для продовження",
|
||||
"black.subscribe.failurePrefix": "Ой!",
|
||||
"black.subscribe.error.generic": "Сталася помилка",
|
||||
"black.subscribe.error.invalidPlan": "Недійсний план",
|
||||
"black.subscribe.error.workspaceRequired": "ID робочого простору обов'язкове",
|
||||
"black.subscribe.error.alreadySubscribed": "Цей робочий простір уже має підписку",
|
||||
"black.subscribe.processing": "Обробка...",
|
||||
"black.subscribe.submit": "Підписатися ${{plan}}",
|
||||
"black.subscribe.form.chargeNotice": "Платіж буде списано лише після активації підписки",
|
||||
"black.subscribe.success.title": "Ви в списку очікування OpenCode Black",
|
||||
"black.subscribe.success.subscriptionPlan": "План підписки",
|
||||
"black.subscribe.success.planName": "OpenCode Black {{plan}}",
|
||||
"black.subscribe.success.amount": "Сума",
|
||||
"black.subscribe.success.amountValue": "${{plan}} на місяць",
|
||||
"black.subscribe.success.paymentMethod": "Спосіб оплати",
|
||||
"black.subscribe.success.dateJoined": "Дата приєднання",
|
||||
"black.subscribe.success.chargeNotice": "Вашу картку буде списано після активації підписки",
|
||||
|
||||
"workspace.nav.zen": "Zen",
|
||||
"workspace.nav.go": "Go",
|
||||
"workspace.nav.usage": "Використання",
|
||||
"workspace.nav.apiKeys": "Ключі API",
|
||||
"workspace.nav.members": "Учасники",
|
||||
"workspace.nav.billing": "Оплата",
|
||||
"workspace.nav.settings": "Налаштування",
|
||||
|
||||
"workspace.home.banner.beforeLink": "Надійні оптимізовані моделі для агентів кодування.",
|
||||
"workspace.lite.banner.beforeLink": "Недорогі моделі кодування для всіх.",
|
||||
"workspace.home.billing.loading": "Завантаження...",
|
||||
"workspace.home.billing.enable": "Увімкнути оплату",
|
||||
"workspace.home.billing.currentBalance": "Поточний баланс",
|
||||
|
||||
"workspace.newUser.feature.tested.title": "Протестовані та перевірені моделі",
|
||||
"workspace.newUser.feature.tested.body":
|
||||
"Ми протестували моделі спеціально для агентів кодування, щоб забезпечити найкращу продуктивність.",
|
||||
"workspace.newUser.feature.quality.title": "Найвища якість",
|
||||
"workspace.newUser.feature.quality.body":
|
||||
"Доступ до моделей, налаштованих для оптимальної продуктивності — без зниження якості.",
|
||||
"workspace.newUser.feature.lockin.title": "Без блокування (Lock-in)",
|
||||
"workspace.newUser.feature.lockin.body":
|
||||
"Використовуйте Zen з будь-яким агентом і продовжуйте користуватися іншими провайдерами.",
|
||||
"workspace.newUser.copyApiKey": "Копіювати ключ API",
|
||||
"workspace.newUser.copyKey": "Копіювати ключ",
|
||||
"workspace.newUser.copied": "Скопійовано!",
|
||||
"workspace.newUser.step.enableBilling": "Увімкнути оплату",
|
||||
"workspace.newUser.step.login.before": "Запустіть",
|
||||
"workspace.newUser.step.login.after": "і виберіть opencode",
|
||||
"workspace.newUser.step.pasteKey": "Вставте ключ API",
|
||||
"workspace.newUser.step.models.before": "Запустіть opencode і виконайте",
|
||||
"workspace.newUser.step.models.after": "щоб вибрати модель",
|
||||
|
||||
"workspace.models.title": "Моделі",
|
||||
"workspace.models.subtitle.beforeLink": "Керуйте доступом учасників до моделей.",
|
||||
"workspace.models.table.model": "Модель",
|
||||
"workspace.models.table.enabled": "Увімкнено",
|
||||
|
||||
"workspace.providers.title": "Принесіть власний ключ (BYOK)",
|
||||
"workspace.providers.subtitle": "Налаштуйте власні ключі API від AI-провайдерів.",
|
||||
"workspace.providers.placeholder": "Введіть ключ API {{provider}} ({{prefix}}...)",
|
||||
"workspace.providers.configure": "Налаштувати",
|
||||
"workspace.providers.edit": "Редагувати",
|
||||
"workspace.providers.delete": "Видалити",
|
||||
"workspace.providers.saving": "Збереження...",
|
||||
"workspace.providers.save": "Зберегти",
|
||||
"workspace.providers.table.provider": "Провайдер",
|
||||
"workspace.providers.table.apiKey": "Ключ API",
|
||||
|
||||
"workspace.usage.title": "Історія використання",
|
||||
"workspace.usage.subtitle": "Останнє використання API та витрати.",
|
||||
"workspace.usage.empty": "Зробіть перший API-запит, щоб почати.",
|
||||
"workspace.usage.table.date": "Дата",
|
||||
"workspace.usage.table.model": "Модель",
|
||||
"workspace.usage.table.input": "Вхід",
|
||||
"workspace.usage.table.output": "Вихід",
|
||||
"workspace.usage.table.cost": "Вартість",
|
||||
"workspace.usage.table.session": "Сесія",
|
||||
"workspace.usage.breakdown.input": "Вхід",
|
||||
"workspace.usage.breakdown.cacheRead": "Читання кешу",
|
||||
"workspace.usage.breakdown.cacheWrite": "Запис кешу",
|
||||
"workspace.usage.breakdown.output": "Вихід",
|
||||
"workspace.usage.breakdown.reasoning": "Міркування",
|
||||
"workspace.usage.subscription": "Black (${{amount}})",
|
||||
"workspace.usage.lite": "Go (${{amount}})",
|
||||
"workspace.usage.byok": "BYOK (${{amount}})",
|
||||
|
||||
"workspace.cost.title": "Вартість",
|
||||
"workspace.cost.subtitle": "Витрати в розрізі моделей.",
|
||||
"workspace.cost.allModels": "Усі моделі",
|
||||
"workspace.cost.allKeys": "Усі ключі",
|
||||
"workspace.cost.deletedSuffix": "(видалено)",
|
||||
"workspace.cost.empty": "Немає даних про використання за вибраний період.",
|
||||
"workspace.cost.subscriptionShort": "підп",
|
||||
|
||||
"workspace.keys.title": "Ключі API",
|
||||
"workspace.keys.subtitle": "Керуйте ключами API для доступу до сервісів opencode.",
|
||||
"workspace.keys.create": "Створити ключ API",
|
||||
"workspace.keys.placeholder": "Введіть назву ключа",
|
||||
"workspace.keys.empty": "Створіть ключ API шлюзу opencode",
|
||||
"workspace.keys.table.name": "Назва",
|
||||
"workspace.keys.table.key": "Ключ",
|
||||
"workspace.keys.table.createdBy": "Створено",
|
||||
"workspace.keys.table.lastUsed": "Останнє використання",
|
||||
"workspace.keys.copyApiKey": "Копіювати ключ API",
|
||||
"workspace.keys.delete": "Видалити",
|
||||
|
||||
"workspace.members.title": "Учасники",
|
||||
"workspace.members.subtitle": "Керуйте учасниками робочого простору та їхніми дозволами.",
|
||||
"workspace.members.invite": "Запросити учасника",
|
||||
"workspace.members.inviting": "Запрошення...",
|
||||
"workspace.members.beta.beforeLink": "Робочі простори безкоштовні для команд під час бета-версії.",
|
||||
"workspace.members.form.invitee": "Запрошений",
|
||||
"workspace.members.form.emailPlaceholder": "Введіть email",
|
||||
"workspace.members.form.role": "Роль",
|
||||
"workspace.members.form.monthlyLimit": "Місячний ліміт витрат",
|
||||
"workspace.members.noLimit": "Без ліміту",
|
||||
"workspace.members.noLimitLowercase": "без ліміту",
|
||||
"workspace.members.invited": "запрошено",
|
||||
"workspace.members.edit": "Редагувати",
|
||||
"workspace.members.delete": "Видалити",
|
||||
"workspace.members.saving": "Збереження...",
|
||||
"workspace.members.save": "Зберегти",
|
||||
"workspace.members.table.email": "Email",
|
||||
"workspace.members.table.role": "Роль",
|
||||
"workspace.members.table.monthLimit": "Ліміт на місяць",
|
||||
"workspace.members.role.admin": "Адміністратор",
|
||||
"workspace.members.role.adminDescription": "Може керувати моделями, учасниками та оплатою",
|
||||
"workspace.members.role.member": "Учасник",
|
||||
"workspace.members.role.memberDescription": "Може створювати ключі API лише для себе",
|
||||
|
||||
"workspace.settings.title": "Налаштування",
|
||||
"workspace.settings.subtitle": "Оновіть назву робочого простору та налаштування.",
|
||||
"workspace.settings.workspaceName": "Назва робочого простору",
|
||||
"workspace.settings.defaultName": "Стандартна",
|
||||
"workspace.settings.updating": "Оновлення...",
|
||||
"workspace.settings.save": "Зберегти",
|
||||
"workspace.settings.edit": "Редагувати",
|
||||
|
||||
"workspace.billing.title": "Оплата",
|
||||
"workspace.billing.subtitle.beforeLink": "Керуйте способами оплати.",
|
||||
"workspace.billing.contactUs": "Зв'яжіться з нами",
|
||||
"workspace.billing.subtitle.afterLink": "якщо у вас є запитання.",
|
||||
"workspace.billing.currentBalance": "Поточний баланс",
|
||||
"workspace.billing.add": "Додати $",
|
||||
"workspace.billing.enterAmount": "Введіть суму",
|
||||
"workspace.billing.loading": "Завантаження...",
|
||||
"workspace.billing.addAction": "Додати",
|
||||
"workspace.billing.addBalance": "Поповнити баланс",
|
||||
"workspace.billing.alipay": "Alipay",
|
||||
"workspace.billing.wechat": "WeChat Pay",
|
||||
"workspace.billing.linkedToStripe": "Підключено до Stripe",
|
||||
"workspace.billing.manage": "Керувати",
|
||||
"workspace.billing.enable": "Увімкнути оплату",
|
||||
|
||||
"workspace.monthlyLimit.title": "Місячний ліміт",
|
||||
"workspace.monthlyLimit.subtitle": "Встановіть місячний ліміт використання для облікового запису.",
|
||||
"workspace.monthlyLimit.placeholder": "50",
|
||||
"workspace.monthlyLimit.setting": "Встановлення...",
|
||||
"workspace.monthlyLimit.set": "Встановити",
|
||||
"workspace.monthlyLimit.edit": "Редагувати ліміт",
|
||||
"workspace.monthlyLimit.noLimit": "Ліміт використання не встановлено.",
|
||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Поточне використання за",
|
||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "становить $",
|
||||
|
||||
"workspace.redeem.title": "Активувати купон",
|
||||
"workspace.redeem.subtitle": "Активуйте код купона для отримання коштів або бонусів.",
|
||||
"workspace.redeem.placeholder": "Введіть код купона",
|
||||
"workspace.redeem.redeem": "Активувати",
|
||||
"workspace.redeem.redeeming": "Активація...",
|
||||
"workspace.redeem.success": "Купон успішно активовано.",
|
||||
|
||||
"workspace.reload.title": "Автоматичне поповнення",
|
||||
"workspace.reload.disabled.before": "Автоматичне поповнення",
|
||||
"workspace.reload.disabled.state": "вимкнено",
|
||||
"workspace.reload.disabled.after": "Увімкніть для автоматичного поповнення при низькому балансі.",
|
||||
"workspace.reload.enabled.before": "Автоматичне поповнення",
|
||||
"workspace.reload.enabled.state": "увімкнено",
|
||||
"workspace.reload.enabled.middle": "Ми поповнимо",
|
||||
"workspace.reload.processingFee": "комісія за обробку",
|
||||
"workspace.reload.enabled.after": "коли баланс досягне",
|
||||
"workspace.reload.edit": "Редагувати",
|
||||
"workspace.reload.enable": "Увімкнути",
|
||||
"workspace.reload.enableAutoReload": "Увімкнути автоматичне поповнення",
|
||||
"workspace.reload.reloadAmount": "Поповнити на $",
|
||||
"workspace.reload.whenBalanceReaches": "Коли баланс досягне $",
|
||||
"workspace.reload.saving": "Збереження...",
|
||||
"workspace.reload.save": "Зберегти",
|
||||
"workspace.reload.failedAt": "Поповнення не вдалося о",
|
||||
"workspace.reload.reason": "Причина:",
|
||||
"workspace.reload.updatePaymentMethod": "Оновіть спосіб оплати та спробуйте ще раз.",
|
||||
"workspace.reload.retrying": "Повтор...",
|
||||
"workspace.reload.retry": "Повторити",
|
||||
"workspace.reload.error.paymentFailed": "Платіж не вдався.",
|
||||
|
||||
"workspace.payments.title": "Історія платежів",
|
||||
"workspace.payments.subtitle": "Останні платіжні транзакції.",
|
||||
"workspace.payments.table.date": "Дата",
|
||||
"workspace.payments.table.paymentId": "ID платежу",
|
||||
"workspace.payments.table.amount": "Сума",
|
||||
"workspace.payments.table.receipt": "Квитанція",
|
||||
"workspace.payments.type.credit": "кредит",
|
||||
"workspace.payments.type.subscription": "підписка",
|
||||
"workspace.payments.view": "Переглянути",
|
||||
|
||||
"workspace.black.loading": "Завантаження...",
|
||||
"workspace.black.time.day": "день",
|
||||
"workspace.black.time.days": "дні",
|
||||
"workspace.black.time.hour": "година",
|
||||
"workspace.black.time.hours": "годин(и)",
|
||||
"workspace.black.time.minute": "хвилина",
|
||||
"workspace.black.time.minutes": "хвилин(и)",
|
||||
"workspace.black.time.fewSeconds": "кілька секунд",
|
||||
"workspace.black.subscription.title": "Підписка",
|
||||
"workspace.black.subscription.message": "Ви підписані на OpenCode Black за ${{plan}} на місяць.",
|
||||
"workspace.black.subscription.manage": "Керувати підпискою",
|
||||
"workspace.black.subscription.rollingUsage": "Використання (5 год)",
|
||||
"workspace.black.subscription.weeklyUsage": "Тижневе використання",
|
||||
"workspace.black.subscription.resetsIn": "Скидається через",
|
||||
"workspace.black.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
|
||||
"workspace.black.waitlist.title": "Список очікування",
|
||||
"workspace.black.waitlist.joined": "Ви в списку очікування на план OpenCode Black за ${{plan}} на місяць.",
|
||||
"workspace.black.waitlist.ready": "Ми готові зареєструвати вас на план OpenCode Black за ${{plan}} на місяць.",
|
||||
"workspace.black.waitlist.leave": "Залишити список очікування",
|
||||
"workspace.black.waitlist.leaving": "Вихід...",
|
||||
"workspace.black.waitlist.left": "Вишли",
|
||||
"workspace.black.waitlist.enroll": "Зареєструватися",
|
||||
"workspace.black.waitlist.enrolling": "Реєстрація...",
|
||||
"workspace.black.waitlist.enrolled": "Зареєстровано",
|
||||
"workspace.black.waitlist.enrollNote":
|
||||
"Після натискання «Зареєструватися» підписка почнеться негайно, а картку буде списано.",
|
||||
|
||||
"workspace.lite.loading": "Завантаження...",
|
||||
"workspace.lite.time.day": "день",
|
||||
"workspace.lite.time.days": "дні",
|
||||
"workspace.lite.time.hour": "година",
|
||||
"workspace.lite.time.hours": "годин(и)",
|
||||
"workspace.lite.time.minute": "хвилина",
|
||||
"workspace.lite.time.minutes": "хвилин(и)",
|
||||
"workspace.lite.time.fewSeconds": "кілька секунд",
|
||||
"workspace.lite.subscription.message": "Ви підписані на OpenCode Go.",
|
||||
"workspace.lite.subscription.manage": "Керувати підпискою",
|
||||
"workspace.lite.subscription.rollingUsage": "Ковзне використання",
|
||||
"workspace.lite.subscription.weeklyUsage": "Тижневе використання",
|
||||
"workspace.lite.subscription.monthlyUsage": "Місячне використання",
|
||||
"workspace.lite.subscription.resetsIn": "Скидається через",
|
||||
"workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
|
||||
"workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.',
|
||||
"workspace.lite.black.message":
|
||||
"Ви вже підписані на OpenCode Black або в списку очікування. Спочатку скасуйте підписку, якщо хочете перейти на Go.",
|
||||
"workspace.lite.other.message": "Інший учасник цього робочого простору вже підписаний на OpenCode Go.",
|
||||
"workspace.lite.promo.description": "OpenCode Go починається від {{price}}, потім $10/місяць, із щедрими лімітами.",
|
||||
"workspace.lite.promo.price": "$5 за перший місяць",
|
||||
"workspace.lite.promo.modelsTitle": "Що включено",
|
||||
"workspace.lite.promo.footer": "План призначений для міжнародних користувачів. Ціни можуть змінюватися.",
|
||||
"workspace.lite.promo.subscribe": "Підписатися на Go",
|
||||
"workspace.lite.promo.subscribing": "Перенаправлення...",
|
||||
"workspace.lite.promo.otherMethods": "Інші способи оплати",
|
||||
"workspace.lite.promo.selectMethod": "Виберіть спосіб оплати",
|
||||
|
||||
"download.title": "OpenCode | Завантажити",
|
||||
"download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux",
|
||||
"download.hero.title": "Завантажити OpenCode",
|
||||
"download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux",
|
||||
"download.hero.button": "Завантажити для {{os}}",
|
||||
"download.section.terminal": "Термінал OpenCode",
|
||||
"download.section.desktop": "Десктоп OpenCode (Бета)",
|
||||
"download.section.extensions": "Розширення OpenCode",
|
||||
"download.section.integrations": "Інтеграції OpenCode",
|
||||
"download.action.download": "Завантажити",
|
||||
"download.action.install": "Встановити",
|
||||
|
||||
"download.platform.macosAppleSilicon": "macOS (Apple Silicon)",
|
||||
"download.platform.macosIntel": "macOS (Intel)",
|
||||
"download.platform.windowsX64": "Windows (x64)",
|
||||
"download.platform.linuxDeb": "Linux (.deb)",
|
||||
"download.platform.linuxRpm": "Linux (.rpm)",
|
||||
|
||||
"download.faq.a3.beforeLocal":
|
||||
"Не обов'язково, але ймовірно. Вам знадобиться AI-підписка, якщо ви хочете підключити платного провайдера, хоча ви можете працювати з",
|
||||
"download.faq.a3.localLink": "локальними моделями",
|
||||
"download.faq.a3.afterLocal.beforeZen": "безкоштовно. Хоча ми рекомендуємо",
|
||||
"download.faq.a3.afterZen":
|
||||
", OpenCode працює з усіма популярними провайдерами, такими як OpenAI, Anthropic, xAI тощо.",
|
||||
|
||||
"download.faq.a5.p1": "OpenCode є 100% безкоштовним.",
|
||||
"download.faq.a5.p2.beforeZen":
|
||||
"Будь-які додаткові витрати будуть з вашої підписки у провайдера моделі. Ми рекомендуємо",
|
||||
"download.faq.a5.p2.afterZen": ".",
|
||||
|
||||
"download.faq.a6.p1": "Ваші дані зберігаються лише при створенні посилань для обміну в OpenCode.",
|
||||
"download.faq.a6.p2.beforeShare": "Дізнайтеся більше про",
|
||||
"download.faq.a6.shareLink": "сторінки обміну",
|
||||
|
||||
"enterprise.title": "OpenCode | Enterprise-рішення для вашої організації",
|
||||
"enterprise.meta.description": "Зв'яжіться з OpenCode для Enterprise-рішень",
|
||||
"enterprise.hero.title": "Ваш код належить вам",
|
||||
"enterprise.hero.body1":
|
||||
"OpenCode працює безпечно всередині вашої організації без зберігання даних, ліцензійних обмежень. Почніть пробний період із командою, потім розгорніть через SSO та внутрішній AI-шлюз.",
|
||||
"enterprise.hero.body2": "Дайте знати, чим ми можемо допомогти.",
|
||||
"enterprise.form.name.label": "Повне ім'я",
|
||||
"enterprise.form.name.placeholder": "Джеф Безос",
|
||||
"enterprise.form.role.label": "Посада",
|
||||
"enterprise.form.role.placeholder": "Голова правління",
|
||||
"enterprise.form.company.label": "Компанія",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Робоча електронна адреса",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Номер телефону",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Яку проблему ви намагаєтеся вирішити?",
|
||||
"enterprise.form.message.placeholder": "Нам потрібна допомога з...",
|
||||
"enterprise.form.send": "Надіслати",
|
||||
"enterprise.form.sending": "Надсилання...",
|
||||
"enterprise.form.success": "Повідомлення надіслано, ми зв'яжемося найближчим часом.",
|
||||
"enterprise.form.success.submitted": "Форму успішно надіслано.",
|
||||
"enterprise.form.error.allFieldsRequired": "Усі поля обов'язкові.",
|
||||
"enterprise.form.error.invalidEmailFormat": "Недійсний формат email.",
|
||||
"enterprise.form.error.internalServer": "Внутрішня помилка сервера.",
|
||||
"enterprise.faq.title": "FAQ",
|
||||
"enterprise.faq.q1": "Що таке OpenCode Enterprise?",
|
||||
"enterprise.faq.a1":
|
||||
"OpenCode Enterprise для організацій, які хочуть гарантувати, що код і дані ніколи не залишають їхню інфраструктуру.",
|
||||
"enterprise.faq.q2": "Як почати з OpenCode Enterprise?",
|
||||
"enterprise.faq.a2":
|
||||
"Почніть із внутрішнього тестування з командою. OpenCode за замовчуванням не зберігає код. Потім зв'яжіться з нами для обговорення цін.",
|
||||
"enterprise.faq.q3": "Як працює ціноутворення enterprise?",
|
||||
"enterprise.faq.a3":
|
||||
"Ми пропонуємо ціну за робоче місце. Якщо у вас власний LLM-шлюз, ми не стягуємо плату за токени.",
|
||||
"enterprise.faq.q4": "Чи безпечні мої дані з OpenCode Enterprise?",
|
||||
"enterprise.faq.a4":
|
||||
"Так. OpenCode не зберігає ваш код або контекст. Вся обробка відбувається локально або через прямі API-виклики.",
|
||||
|
||||
"brand.title": "OpenCode | Бренд",
|
||||
"brand.meta.description": "Рекомендації щодо бренду OpenCode",
|
||||
"brand.heading": "Рекомендації щодо бренду",
|
||||
"brand.subtitle": "Ресурси та матеріали для роботи з брендом OpenCode.",
|
||||
"brand.downloadAll": "Завантажити всі матеріали",
|
||||
|
||||
"changelog.title": "OpenCode | Журнал змін",
|
||||
"changelog.meta.description": "Нотатки про випуски та журнал змін OpenCode",
|
||||
"changelog.hero.title": "Журнал змін",
|
||||
"changelog.hero.subtitle": "Нові оновлення та покращення OpenCode",
|
||||
"changelog.empty": "Записів у журналі змін не знайдено.",
|
||||
"changelog.viewJson": "Переглянути JSON",
|
||||
|
||||
"bench.list.title": "Бенчмарк",
|
||||
"bench.list.heading": "Бенчмарки",
|
||||
"bench.list.table.agent": "Агент",
|
||||
"bench.list.table.model": "Модель",
|
||||
"bench.list.table.score": "Результат",
|
||||
"bench.submission.error.allFieldsRequired": "Усі поля обов'язкові.",
|
||||
|
||||
"bench.detail.title": "Бенчмарк — {{task}}",
|
||||
"bench.detail.notFound": "Завдання не знайдено",
|
||||
"bench.detail.na": "Н/Д",
|
||||
"bench.detail.labels.agent": "Агент",
|
||||
"bench.detail.labels.model": "Модель",
|
||||
"bench.detail.labels.task": "Завдання",
|
||||
"bench.detail.labels.repo": "Репозиторій",
|
||||
"bench.detail.labels.from": "Від",
|
||||
"bench.detail.labels.to": "До",
|
||||
"bench.detail.labels.prompt": "Prompt",
|
||||
"bench.detail.labels.commit": "Коміт",
|
||||
"bench.detail.labels.averageDuration": "Середня тривалість",
|
||||
"bench.detail.labels.averageScore": "Середній результат",
|
||||
"bench.detail.labels.averageCost": "Середня вартість",
|
||||
"bench.detail.labels.summary": "Підсумок",
|
||||
"bench.detail.labels.runs": "Запуски",
|
||||
"bench.detail.labels.score": "Результат",
|
||||
"bench.detail.labels.base": "База",
|
||||
"bench.detail.labels.penalty": "Штраф",
|
||||
"bench.detail.labels.weight": "вага",
|
||||
"bench.detail.table.run": "Запуск",
|
||||
"bench.detail.table.score": "Результат (База — Штраф)",
|
||||
"bench.detail.table.cost": "Вартість",
|
||||
"bench.detail.table.duration": "Тривалість",
|
||||
"bench.detail.run.title": "Запуск {{n}}",
|
||||
"bench.detail.rawJson": "Сирий JSON",
|
||||
}
|
||||
@@ -643,6 +643,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||
"workspace.lite.promo.selectMethod": "选择付款方式",
|
||||
|
||||
"workspace.referral.copyLink": "复制链接",
|
||||
"workspace.referral.copied": "已复制",
|
||||
"workspace.referral.overview.title": "邀请好友",
|
||||
"workspace.referral.overview.subtitle": "好友订阅后,您可获得 $5,对方也可获得 $5。",
|
||||
"workspace.referral.instructions.share": "分享您的推荐链接。",
|
||||
"workspace.referral.instructions.subscribe": "好友加入并订阅 Go。",
|
||||
"workspace.referral.instructions.claim": "你们都将获得 $5 使用额度,可用于您的 Go 使用限额。",
|
||||
"workspace.referral.rewards.title": "邀请奖励",
|
||||
"workspace.referral.rewards.description": "将可用的邀请积分应用到您的 Go 用量。",
|
||||
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 个奖励。",
|
||||
"workspace.referral.rewards.empty": "暂无邀请奖励。",
|
||||
"workspace.referral.table.reward": "奖励",
|
||||
"workspace.referral.table.referral": "描述",
|
||||
"workspace.referral.table.date": "日期",
|
||||
"workspace.referral.reward.description.inviter": "已邀请 {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "由 {{email}} 邀请",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "订阅以解锁",
|
||||
"workspace.referral.reward.action.view": "查看奖励",
|
||||
"workspace.referral.reward.action.applied": "奖励已使用",
|
||||
"workspace.referral.reward.source.pendingInviter": "等待对方订阅",
|
||||
"workspace.referral.reward.source.pendingInvitee": "订阅即可解锁奖励",
|
||||
"workspace.referral.reward.source.available": "奖励可使用",
|
||||
"workspace.referral.reward.source.applied": "奖励已使用",
|
||||
"workspace.referral.reward.status.applied": "奖励已使用",
|
||||
"workspace.referral.reward.status.pendingInviter": "订阅以解锁",
|
||||
"workspace.referral.reward.status.pendingInvitee": "订阅以解锁",
|
||||
"workspace.referral.apply.noGo": "订阅以解锁",
|
||||
"workspace.referral.apply.preview": "查看奖励",
|
||||
"workspace.referral.apply.action": "使用",
|
||||
"workspace.referral.apply.confirmTitle": "使用奖励",
|
||||
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣当前工作区的用量。",
|
||||
"workspace.referral.apply.confirmAction": "使用",
|
||||
|
||||
"download.title": "OpenCode | 下载",
|
||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
||||
"download.hero.title": "下载 OpenCode",
|
||||
|
||||
@@ -643,6 +643,39 @@ export const dict = {
|
||||
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||
"workspace.lite.promo.selectMethod": "選擇付款方式",
|
||||
|
||||
"workspace.referral.copyLink": "複製連結",
|
||||
"workspace.referral.copied": "已複製",
|
||||
"workspace.referral.overview.title": "邀請朋友",
|
||||
"workspace.referral.overview.subtitle": "朋友訂閱後,您可獲得 $5,對方也可獲得 $5。",
|
||||
"workspace.referral.instructions.share": "分享您的推薦連結。",
|
||||
"workspace.referral.instructions.subscribe": "朋友加入並訂閱 Go。",
|
||||
"workspace.referral.instructions.claim": "你們都將獲得 $5 使用額度,可用於您的 Go 使用限額。",
|
||||
"workspace.referral.rewards.title": "邀請獎勵",
|
||||
"workspace.referral.rewards.description": "將可用的邀請點數套用至您的 Go 使用量。",
|
||||
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 個獎勵。",
|
||||
"workspace.referral.rewards.empty": "暫無邀請獎勵。",
|
||||
"workspace.referral.table.reward": "獎勵",
|
||||
"workspace.referral.table.referral": "描述",
|
||||
"workspace.referral.table.date": "日期",
|
||||
"workspace.referral.reward.description.inviter": "已邀請 {{email}}",
|
||||
"workspace.referral.reward.description.invitee": "由 {{email}} 邀請",
|
||||
"workspace.referral.reward.action.subscribeUnlock": "訂閱以解鎖",
|
||||
"workspace.referral.reward.action.view": "查看獎勵",
|
||||
"workspace.referral.reward.action.applied": "獎勵已使用",
|
||||
"workspace.referral.reward.source.pendingInviter": "等待對方訂閱",
|
||||
"workspace.referral.reward.source.pendingInvitee": "訂閱即可解鎖獎勵",
|
||||
"workspace.referral.reward.source.available": "獎勵可使用",
|
||||
"workspace.referral.reward.source.applied": "獎勵已使用",
|
||||
"workspace.referral.reward.status.applied": "獎勵已使用",
|
||||
"workspace.referral.reward.status.pendingInviter": "訂閱以解鎖",
|
||||
"workspace.referral.reward.status.pendingInvitee": "訂閱以解鎖",
|
||||
"workspace.referral.apply.noGo": "訂閱以解鎖",
|
||||
"workspace.referral.apply.preview": "查看獎勵",
|
||||
"workspace.referral.apply.action": "使用",
|
||||
"workspace.referral.apply.confirmTitle": "使用獎勵",
|
||||
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣目前工作區的用量。",
|
||||
"workspace.referral.apply.confirmAction": "使用",
|
||||
|
||||
"download.title": "OpenCode | 下載",
|
||||
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
||||
"download.hero.title": "下載 OpenCode",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Key } from "~/i18n"
|
||||
import type { useI18n } from "~/context/i18n"
|
||||
|
||||
type ResetTimeKeys = {
|
||||
day: Key
|
||||
days: Key
|
||||
hour: Key
|
||||
hours: Key
|
||||
minute: Key
|
||||
minutes: Key
|
||||
fewSeconds: Key
|
||||
}
|
||||
|
||||
export const liteResetTimeKeys = {
|
||||
day: "workspace.lite.time.day",
|
||||
days: "workspace.lite.time.days",
|
||||
hour: "workspace.lite.time.hour",
|
||||
hours: "workspace.lite.time.hours",
|
||||
minute: "workspace.lite.time.minute",
|
||||
minutes: "workspace.lite.time.minutes",
|
||||
fewSeconds: "workspace.lite.time.fewSeconds",
|
||||
} satisfies ResetTimeKeys
|
||||
|
||||
export const blackResetTimeKeys = {
|
||||
day: "workspace.black.time.day",
|
||||
days: "workspace.black.time.days",
|
||||
hour: "workspace.black.time.hour",
|
||||
hours: "workspace.black.time.hours",
|
||||
minute: "workspace.black.time.minute",
|
||||
minutes: "workspace.black.time.minutes",
|
||||
fewSeconds: "workspace.black.time.fewSeconds",
|
||||
} satisfies ResetTimeKeys
|
||||
|
||||
export function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>, keys: ResetTimeKeys) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) {
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return `${days} ${days === 1 ? i18n.t(keys.day) : i18n.t(keys.days)} ${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)}`
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours >= 1)
|
||||
return `${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)} ${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
|
||||
if (minutes === 0) return i18n.t(keys.fewSeconds)
|
||||
return `${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const LOCALES = [
|
||||
"ja",
|
||||
"pl",
|
||||
"ru",
|
||||
"uk",
|
||||
"ar",
|
||||
"no",
|
||||
"br",
|
||||
@@ -41,6 +42,7 @@ const LABEL = {
|
||||
ja: "日本語",
|
||||
pl: "Polski",
|
||||
ru: "Русский",
|
||||
uk: "Українська",
|
||||
ar: "العربية",
|
||||
no: "Norsk",
|
||||
br: "Português (Brasil)",
|
||||
@@ -61,6 +63,7 @@ const TAG = {
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
uk: "uk",
|
||||
ar: "ar",
|
||||
no: "no",
|
||||
br: "pt-BR",
|
||||
@@ -81,6 +84,7 @@ const DOCS = {
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
uk: "uk",
|
||||
ar: "ar",
|
||||
no: "nb",
|
||||
br: "pt-br",
|
||||
@@ -104,6 +108,7 @@ const DOCS_SEGMENT = new Set([
|
||||
"ru",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"zh-cn",
|
||||
"zh-tw",
|
||||
])
|
||||
@@ -124,6 +129,7 @@ const DOCS_LOCALE = {
|
||||
ru: "ru",
|
||||
th: "th",
|
||||
tr: "tr",
|
||||
uk: "uk",
|
||||
"zh-cn": "zh",
|
||||
"zh-tw": "zht",
|
||||
} as const satisfies Record<string, Locale>
|
||||
@@ -239,6 +245,7 @@ function match(input: string): Locale | null {
|
||||
if (value.startsWith("ja")) return "ja"
|
||||
if (value.startsWith("pl")) return "pl"
|
||||
if (value.startsWith("ru")) return "ru"
|
||||
if (value.startsWith("uk")) return "uk"
|
||||
if (value.startsWith("ar")) return "ar"
|
||||
if (value.startsWith("tr")) return "tr"
|
||||
if (value.startsWith("th")) return "th"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
|
||||
const REFERRAL_COOKIE = "oc_referral"
|
||||
const REFERRAL_MAX_AGE = 60 * 60 * 24 * 30
|
||||
|
||||
export function normalizeReferralCode(code?: string | null) {
|
||||
return Referral.normalizeCode(code)
|
||||
}
|
||||
|
||||
export function referralCookie(code: string) {
|
||||
return `${REFERRAL_COOKIE}=${encodeURIComponent(code)}; Path=/; Max-Age=${REFERRAL_MAX_AGE}; SameSite=Lax; HttpOnly`
|
||||
}
|
||||
|
||||
export function clearReferralCookie() {
|
||||
return `${REFERRAL_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly`
|
||||
}
|
||||
|
||||
export function referralCodeFromCookieHeader(header: string | null) {
|
||||
if (!header) return undefined
|
||||
|
||||
return normalizeReferralCode(
|
||||
header
|
||||
.split(";")
|
||||
.map((x) => x.trim())
|
||||
.find((x) => x.startsWith(`${REFERRAL_COOKIE}=`))
|
||||
?.slice(`${REFERRAL_COOKIE}=`.length),
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createMiddleware } from "@solidjs/start/middleware"
|
||||
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
|
||||
import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite"
|
||||
|
||||
export default createMiddleware({
|
||||
onRequest(event) {
|
||||
const url = new URL(event.request.url)
|
||||
const locale = fromPathname(url.pathname)
|
||||
if (!locale) return
|
||||
if (locale) {
|
||||
url.pathname = strip(url.pathname)
|
||||
const request = new Request(url, event.request)
|
||||
request.headers.set(LOCALE_HEADER, locale)
|
||||
event.request = request
|
||||
event.response.headers.append("set-cookie", cookie(locale))
|
||||
}
|
||||
|
||||
url.pathname = strip(url.pathname)
|
||||
const request = new Request(url, event.request)
|
||||
request.headers.set(LOCALE_HEADER, locale)
|
||||
event.request = request
|
||||
event.response.headers.append("set-cookie", cookie(locale))
|
||||
const referralCode = normalizeReferralCode(url.searchParams.get("ref"))
|
||||
if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest, route } from "~/lib/language"
|
||||
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
@@ -17,6 +19,7 @@ export async function GET(input: APIEvent) {
|
||||
if (result.err) throw new Error(result.err.message)
|
||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||
if (decoded.err) throw new Error(decoded.err.message)
|
||||
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
|
||||
const session = await useAuthSession()
|
||||
const id = decoded.subject.properties.accountID
|
||||
await session.update((value) => {
|
||||
@@ -32,8 +35,15 @@ export async function GET(input: APIEvent) {
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
if (decoded.subject.properties.newAccount && referralCode) {
|
||||
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
|
||||
console.error("Referral create failed", error)
|
||||
})
|
||||
}
|
||||
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
|
||||
return redirect(route(locale, next))
|
||||
const response = redirect(route(locale, next))
|
||||
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
|
||||
return response
|
||||
} catch (e: any) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -700,65 +700,6 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="workspace-picker"] {
|
||||
[data-slot="workspace-list"] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="workspace-item"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
cursor: pointer;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: hidden;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-active="true"] {
|
||||
background: #161616;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,3 +780,64 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="black-workspace-picker-modal"] {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
[data-slot="workspace-list"] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="workspace-item"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
cursor: pointer;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: hidden;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-active="true"] {
|
||||
background: #161616;
|
||||
|
||||
[data-slot="selected-icon"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,8 +444,13 @@ export default function BlackSubscribe() {
|
||||
</div>
|
||||
|
||||
{/* Workspace picker modal */}
|
||||
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title={i18n.t("black.workspace.selectPlan")}>
|
||||
<div data-slot="workspace-picker">
|
||||
<Modal
|
||||
open={showWorkspacePicker() ?? false}
|
||||
onClose={() => {}}
|
||||
title={i18n.t("black.workspace.selectPlan")}
|
||||
variant="black"
|
||||
>
|
||||
<div data-component="black-workspace-picker-modal" data-slot="workspace-picker">
|
||||
<ul
|
||||
ref={listRef}
|
||||
data-slot="workspace-list"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||
import { User } from "@opencode-ai/console-core/user.js"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
@@ -161,7 +161,9 @@ export async function POST(input: APIEvent) {
|
||||
})
|
||||
|
||||
if (userEmail) {
|
||||
if (coupon === LiteData.firstMonth100Coupon) {
|
||||
if (coupon === LiteData.firstMonth50Coupon) {
|
||||
await Billing.redeemCoupon(userEmail, "GO1MONTH50")
|
||||
} else if (coupon === LiteData.firstMonth100Coupon) {
|
||||
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
|
||||
} else if (coupon === LiteData.threeMonths100Coupon) {
|
||||
await Billing.redeemCoupon(userEmail, "GO3MONTHS100")
|
||||
@@ -172,6 +174,13 @@ export async function POST(input: APIEvent) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await Referral.completeFromLiteSubscription({
|
||||
workspaceID,
|
||||
userID,
|
||||
}).catch((error) => {
|
||||
console.error("Referral sync failed", error)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="workspace-create-modal"] {
|
||||
width: 100%;
|
||||
|
||||
[data-slot="create-form"] {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
|
||||
import { For, createEffect } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, createEffect, createSignal } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
@@ -51,9 +50,7 @@ export function WorkspacePicker() {
|
||||
const i18n = useI18n()
|
||||
const workspaces = createAsync(() => getWorkspaces())
|
||||
const submission = useSubmission(createWorkspace)
|
||||
const [store, setStore] = createStore({
|
||||
showForm: false,
|
||||
})
|
||||
const [showForm, setShowForm] = createSignal(false)
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
const currentWorkspace = () => {
|
||||
@@ -61,12 +58,8 @@ export function WorkspacePicker() {
|
||||
return ws ? ws.name : i18n.t("workspace.select")
|
||||
}
|
||||
|
||||
const handleWorkspaceNew = () => {
|
||||
setStore("showForm", true)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.showForm && inputRef) {
|
||||
if (showForm() && inputRef) {
|
||||
setTimeout(() => inputRef?.focus(), 0)
|
||||
}
|
||||
})
|
||||
@@ -79,7 +72,7 @@ export function WorkspacePicker() {
|
||||
// Reset signals when workspace ID changes
|
||||
createEffect(() => {
|
||||
params.id
|
||||
setStore("showForm", false)
|
||||
setShowForm(false)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -92,32 +85,34 @@ export function WorkspacePicker() {
|
||||
</DropdownItem>
|
||||
)}
|
||||
</For>
|
||||
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
|
||||
<button data-slot="create-item" type="button" onClick={() => setShowForm(true)}>
|
||||
{i18n.t("workspace.createNew")}
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
<Modal open={store.showForm} onClose={() => setStore("showForm", false)} title={i18n.t("workspace.modal.title")}>
|
||||
<form data-slot="create-form" action={createWorkspace} method="post">
|
||||
<div data-slot="create-input-group">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-slot="create-input"
|
||||
type="text"
|
||||
name="workspaceName"
|
||||
placeholder={i18n.t("workspace.modal.placeholder")}
|
||||
required
|
||||
/>
|
||||
<div data-slot="button-group">
|
||||
<button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
|
||||
</button>
|
||||
<Modal open={showForm()} onClose={() => setShowForm(false)} title={i18n.t("workspace.modal.title")}>
|
||||
<div data-component="workspace-create-modal">
|
||||
<form data-slot="create-form" action={createWorkspace} method="post">
|
||||
<div data-slot="create-input-group">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-slot="create-input"
|
||||
type="text"
|
||||
name="workspaceName"
|
||||
placeholder={i18n.t("workspace.modal.placeholder")}
|
||||
required
|
||||
/>
|
||||
<div data-slot="button-group">
|
||||
<button type="button" data-color="ghost" onClick={() => setShowForm(false)}>
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import styles from "./black-section.module.css"
|
||||
import waitlistStyles from "./black-waitlist-section.module.css"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError } from "~/lib/form-error"
|
||||
import { blackResetTimeKeys, formatResetTime } from "~/lib/format-reset-time"
|
||||
|
||||
const querySubscription = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
@@ -52,20 +53,6 @@ const querySubscription = query(async (workspaceID: string) => {
|
||||
}, workspaceID)
|
||||
}, "subscription.get")
|
||||
|
||||
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) {
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return `${days} ${days === 1 ? i18n.t("workspace.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")}`
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours >= 1)
|
||||
return `${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
|
||||
if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
|
||||
return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
|
||||
}
|
||||
|
||||
const cancelWaitlist = action(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
@@ -209,7 +196,7 @@ export function BlackSection() {
|
||||
</div>
|
||||
<span data-slot="reset-time">
|
||||
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
|
||||
{formatResetTime(sub().rollingUsage.resetInSec, i18n, blackResetTimeKeys)}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="usage-item">
|
||||
@@ -222,7 +209,7 @@ export function BlackSection() {
|
||||
</div>
|
||||
<span data-slot="reset-time">
|
||||
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
|
||||
{formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
|
||||
{formatResetTime(sub().weeklyUsage.resetInSec, i18n, blackResetTimeKeys)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { IconGo } from "~/component/icon"
|
||||
import { GoReferralSection, queryGoReferral } from "~/component/go-referral"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { LiteSection } from "./lite-section"
|
||||
import { LiteSection, queryLiteSubscription } from "./lite-section"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const referral = createAsync(() => queryGoReferral(params.id!))
|
||||
const lite = createAsync(() => queryLiteSubscription(params.id!))
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
@@ -23,7 +29,10 @@ export default function () {
|
||||
</section>
|
||||
|
||||
<div data-slot="sections">
|
||||
<LiteSection />
|
||||
<LiteSection lite={lite()} />
|
||||
<Show when={referral()} fallback={<section>{i18n.t("workspace.lite.loading")}</section>}>
|
||||
{(summary) => <GoReferralSection workspaceID={params.id!} summary={summary()} lite={lite()} />}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user