Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75afd01b4b | ||
|
|
7724d65e92 | ||
|
|
5e76f58637 | ||
|
|
237d1e2060 | ||
|
|
aa498f4cbb |
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
|
TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
|
||||||
|
|
||||||
- Ürün sürümü: **1.0.0-beta.1**, ilk paket hedefi Windows x64.
|
- Ürün sürümü: **1.0.0-beta.4**, ilk paket hedefi Windows x64.
|
||||||
- Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir.
|
- Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir.
|
||||||
- İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir.
|
- İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir.
|
||||||
Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner.
|
Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner.
|
||||||
@@ -10,6 +10,9 @@ TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
|
|||||||
- TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının
|
- TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının
|
||||||
kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir.
|
kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir.
|
||||||
Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz.
|
Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz.
|
||||||
|
- AITURK model kataloğu arka planda otomatik yenilenir. Açık model listeleri
|
||||||
|
dakikada bir sunucuyu kontrol eder; 9Router ve OmniRoute'ta yeni yayımlanan
|
||||||
|
kayıtlar elle liste düzenlemeden görünür. Admin engeli sunucuda uygulanır.
|
||||||
- Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda
|
- Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda
|
||||||
çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir.
|
çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* macOS displays these explanations when the user grants an OS permission. */
|
||||||
|
"CFBundleDisplayName" = "AITURK IDE";
|
||||||
|
"NSAudioCaptureUsageDescription" = "AITURK IDE, sesli konuşmalar için ses yakalamayı kullanır.";
|
||||||
|
"NSCameraUsageDescription" = "AITURK IDE, etkinleştirdiğiniz bir eklenti veya özellik istediğinde kamerayı kullanır.";
|
||||||
|
"NSMicrophoneUsageDescription" = "AITURK IDE, sesli giriş ve sesli konuşmalar için mikrofonu kullanır.";
|
||||||
|
"NSCalendarsUsageDescription" = "AITURK IDE, istediğiniz toplantı ve zamanlama işlemlerini yapmak için Takvim’e erişir.";
|
||||||
|
"NSCalendarsFullAccessUsageDescription" = "AITURK IDE, açıkça istediğinizde etkinlikleri okumak ve yönetmek için Takvim’e tam erişim ister.";
|
||||||
|
"NSRemindersUsageDescription" = "AITURK IDE, istediğiniz kişisel asistan ve zamanlama işlemlerini yapmak için Anımsatıcılar’a erişir.";
|
||||||
|
"NSRemindersFullAccessUsageDescription" = "AITURK IDE, açıkça istediğinizde anımsatıcıları okumak ve yönetmek için Anımsatıcılar’a tam erişim ister.";
|
||||||
|
"NSScreenCaptureUsageDescription" = "AITURK IDE, ajandan ekran görüntüsü veya ekran kaydı istediğinizde ekranı yakalar.";
|
||||||
|
"NSLocalNetworkUsageDescription" = "AITURK IDE, etkinleştirdiğiniz bir eklenti veya özellik istediğinde yerel ağınızdaki cihazlara bağlanır.";
|
||||||
|
"NSAppleMusicUsageDescription" = "AITURK IDE, etkinleştirdiğiniz bir eklenti veya özellik istediğinde müzik arşivinize erişir.";
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# macOS yerel menüsünün Türkçe desteği
|
||||||
|
|
||||||
|
Dosya, Düzen, Görünüm, Pencere, Yardım ve uygulama menüsündeki komutlar
|
||||||
|
Türkçe gösterilir. Ana pencerenin dil seçimi mevcut `display.language`
|
||||||
|
ayarından gelir. Dar kapsamlı `setUiLanguage` köprüsü yalnızca menü
|
||||||
|
etiketlerini günceller; ayrı bir tercih dosyası veya sunucu ayarı oluşturmaz.
|
||||||
|
Dil kaydı başarısız olursa arayüzle birlikte menü dili de geri alınır.
|
||||||
|
Yardımcı pencereler ana pencerenin menü dilini değiştiremez.
|
||||||
|
|
||||||
|
Menü öğelerinin eylemleri, Electron rolleri ve klavye kısayolları korunur.
|
||||||
|
Özellikle renderer tarafından yönetilen yeni pencere, klasör açma, sekme
|
||||||
|
kapatma ve sayfa yenileme komutlarına yerel kısayol eklenmez. İngilizce
|
||||||
|
seçilince özgün İngilizce şablon kullanılır; diğer diller yerel menüde
|
||||||
|
mevcut İngilizce desteğini kullanmaya devam eder.
|
||||||
|
|
||||||
|
Paket doğrulama yolları `package.json` içindeki AITURK ürün ve çalıştırılabilir
|
||||||
|
dosya adlarını kullanır. Intel Mac için `release/mac`, Apple Silicon için
|
||||||
|
`release/mac-arm64` aranır. Temiz kurulum denemesi açıkça izole
|
||||||
|
`AITURK_IDE_HOME` ve Electron kullanıcı dizini seçer. Başka bir Hermes
|
||||||
|
kurulumunun çalışma dizinine yönelmez.
|
||||||
|
|
||||||
|
## Kanıt ve yayın sınırı
|
||||||
|
|
||||||
|
- Menü birim testleri eylem, rol ve kısayol bütünlüğünü; kaynak pencere ve
|
||||||
|
dil doğrulamasını kontrol eder.
|
||||||
|
- React testi yüklenen dil, iyimser geçiş ve kayıt hatasında geri dönüşün
|
||||||
|
yerel köprüye iletildiğini doğrular.
|
||||||
|
- Gerçek Electron E2E testi köprüyü kullanır. macOS'ta yerel menü etiketlerini,
|
||||||
|
Windows'ta uygulama menüsünün kapalı kalmasını kontrol eder.
|
||||||
|
- Windows üzerinde Türkçe ayar kaydı ve köprü testleri geçti. Paket yolu,
|
||||||
|
mevcut Windows beta.5 dosyası üzerinde açılış ve içerik kontrolüyle doğrulandı.
|
||||||
|
|
||||||
|
Bu kaynak değişikliği, yayımlanan Windows beta.5 dosyasını değiştirmez.
|
||||||
|
macOS dalındaki E2E testi, DMG/ZIP derlemesi ve gerçek Mac'te çalıştırma henüz
|
||||||
|
doğrulanmadı. Mac paketi bu kanıtlar tamamlanmadan yayımlanmış sayılmaz.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# AITURK IDE 1.0.0-beta.5 — Türkçe dil kapsamı
|
||||||
|
|
||||||
|
Türkçe dil desteği, menü başlıklarından ayar alanlarının açıklamalarına,
|
||||||
|
seçeneklerine, doğrulama ve işlem sonuçlarına genişletildi. Gelişmiş bağlantı,
|
||||||
|
yerel model, bilgisayar kullanımı, araç, bellek, profil, mesajlaşma ve görev
|
||||||
|
ayarları Türkçe metinler sunar. Faturalandırma ekranındaki planlar, tutar
|
||||||
|
doğrulamaları, otomatik yükleme ve ödeme hataları da dil seçimine uyar.
|
||||||
|
|
||||||
|
Sohbet filtreleri, zaman çizelgesi, bellek/beceri işlemleri, görsel üretim
|
||||||
|
kurulumu, Bots ve Kanban eklentileri Türkçe kapsamına alındı. Hızlı giriş ve
|
||||||
|
masaüstü maskotu ana pencerenin dil seçimini mevcut IPC durum aktarımıyla
|
||||||
|
izler; ayrı bir sunucu bağlantısı veya ayar yazıcısı oluşturmaz.
|
||||||
|
|
||||||
|
Çeviri yalnızca sunum metinlerini değiştirir. Model ve sağlayıcı kimlikleri,
|
||||||
|
özel profil/proje adları, API alanları, ayar enum değerleri, oturum hedefleri,
|
||||||
|
ödeme tutarları ve idempotency davranışı korunur. Harici sağlayıcının kendi
|
||||||
|
hata mesajları ve kullanıcı tarafından eklenmiş açıklamalar değiştirilmez.
|
||||||
|
|
||||||
|
## Doğrulama
|
||||||
|
|
||||||
|
- Türkçe sözlük, İngilizceye geri düşmeden tüm kayıtlı çekirdek mesaj
|
||||||
|
anahtarlarını ve ayar alanlarının etiket/açıklamalarını kapsar.
|
||||||
|
- Arayüz testleri Türkçe seçeneklerin özgün API değerlerini kaydettiğini,
|
||||||
|
ödeme tutarlarının değişmediğini ve dil geçişinde hızlı giriş taslağı ile
|
||||||
|
hedef oturum kimliğinin korunduğunu kontrol eder.
|
||||||
|
- Gerçek Electron uygulamasında güvenlik ayarındaki “Elle onayla” seçeneği
|
||||||
|
masaüstü köprüsünden kaydedilir; API cevabı ve izole kullanıcı dizinindeki
|
||||||
|
YAML dosyasında `approvals.mode: manual` doğrulanır.
|
||||||
|
- Paket doğrulaması, temiz kaynak commit'ini içeren `install-stamp.json`,
|
||||||
|
Windows kurulum dosyasının SHA-256 değeri ve indirilen dosyanın eşleşmesiyle
|
||||||
|
tamamlanmalıdır. Bir derlemenin varlığı, yayımlandığı anlamına gelmez.
|
||||||
|
|
||||||
|
## Platform durumu
|
||||||
|
|
||||||
|
Windows paketi önceki beta sürümünden ayrı, değiştirilmeyen bir yayın varlığı
|
||||||
|
olarak hazırlanır. Windows Authenticode imzası yoksa yayın notunda açıkça
|
||||||
|
belirtilir; dosya özeti yayıncı imzası olarak sunulmaz.
|
||||||
|
|
||||||
|
macOS için Apple Silicon ve Intel derleme komutları ile Türkçe sistem izin
|
||||||
|
metinleri eklendi. Gerçek macOS derleme/çalıştırma ve varsa Apple imzalama/
|
||||||
|
notarizasyon doğrulaması tamamlanmadan Mac paketi hazır olarak duyurulmaz.
|
||||||
|
|
||||||
|
CLI 1.10.24 ayrı bir üründür ve bu IDE sürümünün yayınlanması CLI'nin imzalı
|
||||||
|
yayın kontrolünü karşılamaz.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# AITURK IDE 1.0.0-beta.6
|
||||||
|
|
||||||
|
Türkçe desteği yerel dosya kaydetme, görsel kaydetme, klasör seçme, bulut
|
||||||
|
bağlantısı ve güncelleme pencerelerine genişletildi. Devam eden işler varken
|
||||||
|
çıkış uyarısı Türkçe gösterilir; sohbet adları, sayılar ve çıkış koruması
|
||||||
|
korunur. Ana pencerenin dil değişikliği Windows'taki yerel pencere başlıklarına
|
||||||
|
ve macOS uygulama menüsüne aktarılır.
|
||||||
|
|
||||||
|
Harita paylaşım kodunun geçersiz, kısa, bozuk veya uyumsuz sürümde olması
|
||||||
|
durumunda gösterilen hatalar çevrilir. İçe/dışa aktarma biçimi, sürüm kontrolü
|
||||||
|
ve sağlama toplamı denetimi değişmez; sürüm hatası beklenen ve gelen sürümü
|
||||||
|
göstermeye devam eder.
|
||||||
|
|
||||||
|
Paket doğrulama yolları AITURK adına göre düzeltilmiştir. Bu sürümün Windows
|
||||||
|
paketi ayrı bir yayın varlığı olarak hazırlanır; önceki beta.5 değiştirilmez.
|
||||||
|
Windows Authenticode imzası yoktur. macOS kaynak ve paket hazırlıkları
|
||||||
|
mevcuttur, ancak gerçek Mac'te derleme ve çalıştırma doğrulanmadan Mac paketi
|
||||||
|
hazır olarak duyurulmaz. CLI 1.10.24'ün imzalı yayını ayrı bir süreçtir.
|
||||||
@@ -27,12 +27,15 @@ import * as path from 'node:path'
|
|||||||
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||||
|
|
||||||
import { resolveElectronBinary } from './electron-binary'
|
import { resolveElectronBinary } from './electron-binary'
|
||||||
import { startMockServer, type MockServerOptions } from './mock-server'
|
import { type MockServerOptions, startMockServer } from './mock-server'
|
||||||
import { installErrorBannerGuard } from './test'
|
import { installErrorBannerGuard } from './test'
|
||||||
|
|
||||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||||
|
const PACKAGE_CONFIG = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8')).build as {
|
||||||
|
executableName: string
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
|
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
|
||||||
|
|
||||||
@@ -259,6 +262,8 @@ export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...clean,
|
...clean,
|
||||||
|
// AITURK ignores inherited Hermes state; use the same explicit sandbox.
|
||||||
|
AITURK_IDE_HOME: sandbox.hermesHome,
|
||||||
HERMES_HOME: sandbox.hermesHome,
|
HERMES_HOME: sandbox.hermesHome,
|
||||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||||
@@ -366,6 +371,7 @@ export interface MockBackendFixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MockBackendOptions {
|
export interface MockBackendOptions {
|
||||||
|
mockServer?: MockServerOptions
|
||||||
/**
|
/**
|
||||||
* Optional YAML lines to inject under the `display:` section of the
|
* Optional YAML lines to inject under the `display:` section of the
|
||||||
* generated config.yaml. Used by the interim-message e2e test to toggle
|
* generated config.yaml. Used by the interim-message e2e test to toggle
|
||||||
@@ -385,10 +391,6 @@ export interface MockBackendOptions {
|
|||||||
* 3. Launch the desktop app
|
* 3. Launch the desktop app
|
||||||
* 4. Return handles for test interaction
|
* 4. Return handles for test interaction
|
||||||
*/
|
*/
|
||||||
export interface MockBackendOptions {
|
|
||||||
mockServer?: MockServerOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
|
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
|
||||||
// 1. Start mock server
|
// 1. Start mock server
|
||||||
const mock = await startMockServer(options.mockServer)
|
const mock = await startMockServer(options.mockServer)
|
||||||
@@ -520,16 +522,19 @@ providers:
|
|||||||
*/
|
*/
|
||||||
function resolvePackagedBinaryPath(): string {
|
function resolvePackagedBinaryPath(): string {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
|
return path.join(RELEASE_ROOT, 'win-unpacked', `${PACKAGE_CONFIG.executableName}.exe`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
|
|
||||||
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
|
return path.join(
|
||||||
|
RELEASE_ROOT, arch === 'x64' ? 'mac' : `mac-${arch}`,
|
||||||
|
`${PACKAGE_CONFIG.executableName}.app`, 'Contents', 'MacOS', PACKAGE_CONFIG.executableName
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
|
return path.join(RELEASE_ROOT, 'linux-unpacked', PACKAGE_CONFIG.executableName)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
|
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
|
||||||
@@ -643,6 +648,7 @@ export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFi
|
|||||||
// `position: fixed; inset: 0`. If the hit element or an ancestor
|
// `position: fixed; inset: 0`. If the hit element or an ancestor
|
||||||
// is a full-viewport fixed overlay, we're still covered.
|
// is a full-viewport fixed overlay, we're still covered.
|
||||||
let node: Element | null = el
|
let node: Element | null = el
|
||||||
|
|
||||||
while (node) {
|
while (node) {
|
||||||
const cs = window.getComputedStyle(node)
|
const cs = window.getComputedStyle(node)
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ test.afterAll(async () => {
|
|||||||
fixture = null
|
fixture = null
|
||||||
})
|
})
|
||||||
|
|
||||||
test('window opens with the Hermes title', async () => {
|
test('window opens with the AITURK title', async () => {
|
||||||
const title = await fixture!.page.title()
|
const title = await fixture!.page.title()
|
||||||
expect(title).toContain('Hermes')
|
expect(title).toContain('AITURK')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('renderer loads and shows DOM content', async () => {
|
test('renderer loads and shows DOM content', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
|
||||||
|
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||||
|
import { expect, test } from './test'
|
||||||
|
|
||||||
|
let fixture: MockBackendFixture | null = null
|
||||||
|
type DesktopTestWindow = Window & {
|
||||||
|
hermesDesktop: {
|
||||||
|
api<T = unknown>(request: { path: string }): Promise<T>
|
||||||
|
setUiLanguage(locale: string): void
|
||||||
|
selectSavePath(options?: { title?: string; defaultPath?: string }): Promise<string | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.setTimeout(150_000)
|
||||||
|
fixture = await setupMockBackend({ extraDisplayConfig: ' language: tr' })
|
||||||
|
await waitForAppReady(fixture, 120_000)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const status = await fixture!.page.evaluate(() =>
|
||||||
|
(window as unknown as DesktopTestWindow).hermesDesktop.api({ path: '/api/status' })
|
||||||
|
)
|
||||||
|
|
||||||
|
return Boolean(status)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await fixture?.cleanup()
|
||||||
|
fixture = null
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Turkish safety settings persist the backend enum through the real desktop bridge', async () => {
|
||||||
|
const { page, sandbox } = fixture!
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.location.hash = '/settings?tab=config%3Asafety'
|
||||||
|
})
|
||||||
|
await expect(page.getByText('Gizli bilgileri maskele', { exact: true })).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByText('Algılanan gizli bilgileri mümkün olduğunda modelin görebildiği içerikten gizler.', { exact: true })
|
||||||
|
).toBeVisible()
|
||||||
|
const approvals = page.locator('[data-tour="field-approvals.mode"]')
|
||||||
|
await approvals.getByRole('combobox').click()
|
||||||
|
await page.getByRole('option', { name: 'Elle onayla', exact: true }).click()
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const config = await page.evaluate(() =>
|
||||||
|
(window as unknown as DesktopTestWindow).hermesDesktop.api<Record<string, unknown>>({ path: '/api/config' })
|
||||||
|
)
|
||||||
|
|
||||||
|
return (config.approvals as { mode?: string } | undefined)?.mode
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
)
|
||||||
|
.toBe('manual')
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
return execFileSync(
|
||||||
|
process.env.HERMES_DESKTOP_PYTHON || 'python',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'import pathlib, sys, yaml; print(yaml.safe_load(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")).get("approvals", {}).get("mode", ""))',
|
||||||
|
path.join(sandbox.hermesHome, 'config.yaml')
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' }
|
||||||
|
).trim()
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
)
|
||||||
|
.toBe('manual')
|
||||||
|
await expect(approvals.getByRole('combobox')).toContainText('Elle onayla')
|
||||||
|
await page.screenshot({ path: test.info().outputPath('turkish-safety-settings.png'), fullPage: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('native language bridge updates the Mac menu and preserves menu-free Windows', async () => {
|
||||||
|
const { app, page } = fixture!
|
||||||
|
const platform = await app.evaluate(() => process.platform)
|
||||||
|
for (const locale of ['en', 'tr']) {
|
||||||
|
await page.evaluate(language => {
|
||||||
|
;(window as unknown as DesktopTestWindow).hermesDesktop.setUiLanguage(language)
|
||||||
|
}, locale)
|
||||||
|
await expect
|
||||||
|
.poll(() => app.evaluate(({ Menu }) => Menu.getApplicationMenu()?.items.map((item: { label: string }) => item.label) ?? null))
|
||||||
|
.toEqual(
|
||||||
|
platform === 'darwin'
|
||||||
|
? expect.arrayContaining([locale === 'tr' ? 'Dosya' : 'File', locale === 'tr' ? 'Düzen' : 'Edit'])
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('native save dialog follows language while preserving caller title and path', async () => {
|
||||||
|
const { app, page } = fixture!
|
||||||
|
await app.evaluate(({ dialog }) => {
|
||||||
|
const state = globalThis as typeof globalThis & { originalSave?: unknown; saveOptions?: unknown }
|
||||||
|
state.originalSave = dialog.showSaveDialog
|
||||||
|
dialog.showSaveDialog = async (_parent: unknown, options: unknown) => {
|
||||||
|
state.saveOptions = options
|
||||||
|
return { canceled: true, filePath: '' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
for (const locale of ['tr', 'en']) {
|
||||||
|
const result = await page.evaluate(async language => {
|
||||||
|
const bridge = (window as unknown as DesktopTestWindow).hermesDesktop
|
||||||
|
bridge.setUiLanguage(language)
|
||||||
|
return bridge.selectSavePath({ defaultPath: 'My custom archive.zip' })
|
||||||
|
}, locale)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
const options = await app.evaluate(() => (globalThis as typeof globalThis & { saveOptions?: unknown }).saveOptions)
|
||||||
|
expect(options).toMatchObject({ title: locale === 'tr' ? 'Kaydet' : 'Save', defaultPath: 'My custom archive.zip' })
|
||||||
|
}
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const bridge = (window as unknown as DesktopTestWindow).hermesDesktop
|
||||||
|
bridge.setUiLanguage('tr')
|
||||||
|
await bridge.selectSavePath({ title: 'My custom title' })
|
||||||
|
})
|
||||||
|
expect(await app.evaluate(() => (globalThis as typeof globalThis & { saveOptions?: unknown }).saveOptions))
|
||||||
|
.toMatchObject({ title: 'My custom title' })
|
||||||
|
} finally {
|
||||||
|
await app.evaluate(({ dialog }) => {
|
||||||
|
const state = globalThis as typeof globalThis & { originalSave?: typeof dialog.showSaveDialog; saveOptions?: unknown }
|
||||||
|
dialog.showSaveDialog = state.originalSave!
|
||||||
|
delete state.originalSave
|
||||||
|
delete state.saveOptions
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -13,6 +13,7 @@ test('a package upgrade refreshes only a proven managed runtime, once per packag
|
|||||||
const stamp = {commit: 'b'.repeat(40), source: 'git'}
|
const stamp = {commit: 'b'.repeat(40), source: 'git'}
|
||||||
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, VALID_MARKER), true)
|
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, VALID_MARKER), true)
|
||||||
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, {...VALID_MARKER, pinnedCommit: stamp.commit}), false)
|
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, {...VALID_MARKER, pinnedCommit: stamp.commit}), false)
|
||||||
|
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, {...VALID_MARKER, pinnedCommit: 'c'.repeat(40), packageCommit: stamp.commit}), false)
|
||||||
assert.equal(needsPackagedRuntimeUpgrade(false, stamp, VALID_MARKER), false)
|
assert.equal(needsPackagedRuntimeUpgrade(false, stamp, VALID_MARKER), false)
|
||||||
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, null), false)
|
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, null), false)
|
||||||
assert.equal(needsPackagedRuntimeUpgrade(true, {commit: '0'.repeat(40), source: 'fallback'}, VALID_MARKER), false)
|
assert.equal(needsPackagedRuntimeUpgrade(true, {commit: '0'.repeat(40), source: 'fallback'}, VALID_MARKER), false)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface BootstrapMarkerLike {
|
export interface BootstrapMarkerLike {
|
||||||
pinnedCommit?: unknown
|
pinnedCommit?: unknown
|
||||||
|
packageCommit?: unknown
|
||||||
schemaVersion?: unknown
|
schemaVersion?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export function needsPackagedRuntimeUpgrade(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return Boolean(packaged && stamp?.source !== 'fallback' &&
|
return Boolean(packaged && stamp?.source !== 'fallback' &&
|
||||||
/^[0-9a-f]{40}$/i.test(stamp?.commit || '') && !/^0+$/.test(stamp?.commit || '') &&
|
/^[0-9a-f]{40}$/i.test(stamp?.commit || '') && !/^0+$/.test(stamp?.commit || '') &&
|
||||||
hasValidBootstrapMarker(marker, 1) && marker?.pinnedCommit !== stamp?.commit)
|
hasValidBootstrapMarker(marker, 1) && (marker?.packageCommit || marker?.pinnedCommit) !== stamp?.commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveRuntimeState {
|
export interface ActiveRuntimeState {
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', ()
|
|||||||
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
|
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
|
||||||
resolveHead: () => realHead
|
resolveHead: () => realHead
|
||||||
}),
|
}),
|
||||||
'd'.repeat(40),
|
realHead,
|
||||||
'packaged real pin wins over checkout HEAD'
|
'marker reports the actual installed checkout, including a preserved newer runtime'
|
||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
|
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
|
||||||
|
|||||||
@@ -111,16 +111,16 @@ function resolveMarkerPinnedCommit(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const resolveHead = opts.resolveHead || resolveCheckoutHead
|
const resolveHead = opts.resolveHead || resolveCheckoutHead
|
||||||
|
|
||||||
if (installStamp && isPinnedCommit(installStamp.commit)) {
|
|
||||||
return installStamp.commit
|
|
||||||
}
|
|
||||||
|
|
||||||
const head = resolveHead(activeRoot)
|
const head = resolveHead(activeRoot)
|
||||||
|
|
||||||
if (head) {
|
if (head) {
|
||||||
return head
|
return head
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (installStamp && isPinnedCommit(installStamp.commit)) {
|
||||||
|
return installStamp.commit
|
||||||
|
}
|
||||||
|
|
||||||
return readExistingPinnedCommit(activeRoot)
|
return readExistingPinnedCommit(activeRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,14 +918,14 @@ async function runBootstrap(opts) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const existingCheckout = hasExistingGitCheckout(activeRoot)
|
const existingCheckout = hasExistingGitCheckout(activeRoot)
|
||||||
const pinCommit = !existingCheckout
|
const pinCommit = Boolean(installStamp && isPinnedCommit(installStamp.commit)) || !existingCheckout
|
||||||
|
|
||||||
if (existingCheckout && installStamp && installStamp.commit) {
|
if (existingCheckout && installStamp && installStamp.commit) {
|
||||||
emit({
|
emit({
|
||||||
type: 'log',
|
type: 'log',
|
||||||
line:
|
line:
|
||||||
`[bootstrap] existing checkout detected at ${activeRoot}; ` +
|
`[bootstrap] existing checkout detected at ${activeRoot}; ` +
|
||||||
`not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}`
|
`applying packaged install stamp ${installStamp.commit.slice(0, 12)} with installer rollback protection`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,6 +1002,7 @@ async function runBootstrap(opts) {
|
|||||||
|
|
||||||
const markerPayload = {
|
const markerPayload = {
|
||||||
pinnedCommit,
|
pinnedCommit,
|
||||||
|
packageCommit: installStamp && isPinnedCommit(installStamp.commit) ? installStamp.commit : null,
|
||||||
pinnedBranch: installStamp ? installStamp.branch : null
|
pinnedBranch: installStamp ? installStamp.branch : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ import {
|
|||||||
resolveOauthRestAuth,
|
resolveOauthRestAuth,
|
||||||
resolveReadinessProbeAuth
|
resolveReadinessProbeAuth
|
||||||
} from './native-auth-decisions'
|
} from './native-auth-decisions'
|
||||||
|
import { nativeDialogCopy } from './native-dialog-copy'
|
||||||
|
import { localizeNativeMenu, type NativeMenuLocale, resolveNativeMenuLocale } from './native-menu-locale'
|
||||||
import {
|
import {
|
||||||
nativeRefreshUrl,
|
nativeRefreshUrl,
|
||||||
type NativeTokenSet,
|
type NativeTokenSet,
|
||||||
@@ -2312,8 +2314,8 @@ async function waitForUpdateToFinish() {
|
|||||||
rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`)
|
rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`)
|
||||||
dialog.showMessageBox({
|
dialog.showMessageBox({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
title: 'Hermes update',
|
title: nativeText().update,
|
||||||
message: 'The update finished, but needs one more step',
|
message: nativeText().updateMore,
|
||||||
detail: result.message
|
detail: result.message
|
||||||
})
|
})
|
||||||
} else if (result && result.ok) {
|
} else if (result && result.ok) {
|
||||||
@@ -2321,8 +2323,8 @@ async function waitForUpdateToFinish() {
|
|||||||
} else if (result) {
|
} else if (result) {
|
||||||
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
|
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
|
||||||
dialog.showErrorBox(
|
dialog.showErrorBox(
|
||||||
'Hermes update did not finish',
|
nativeText().updateFailed,
|
||||||
`${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}`
|
`${result.message}\n\n${nativeText().details}: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -4597,6 +4599,7 @@ function writeBootstrapMarker(payload) {
|
|||||||
const merged = {
|
const merged = {
|
||||||
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
|
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
|
||||||
pinnedCommit: payload.pinnedCommit || null,
|
pinnedCommit: payload.pinnedCommit || null,
|
||||||
|
packageCommit: payload.packageCommit || null,
|
||||||
pinnedBranch: payload.pinnedBranch || null,
|
pinnedBranch: payload.pinnedBranch || null,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
desktopVersion: app.getVersion()
|
desktopVersion: app.getVersion()
|
||||||
@@ -6085,11 +6088,11 @@ async function saveImageFromUrl(rawUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await dialog.showSaveDialog(mainWindow, {
|
const result = await dialog.showSaveDialog(mainWindow, {
|
||||||
title: 'Save Image',
|
title: nativeText().saveImage,
|
||||||
defaultPath: downloadsDir ? path.join(downloadsDir, fallbackName) : fallbackName,
|
defaultPath: downloadsDir ? path.join(downloadsDir, fallbackName) : fallbackName,
|
||||||
filters: [
|
filters: [
|
||||||
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
|
{ name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
|
||||||
{ name: 'All Files', extensions: ['*'] }
|
{ name: nativeText().allFiles, extensions: ['*'] }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -6754,11 +6757,14 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow)
|
|||||||
webContents.send('hermes:window-state-changed', state)
|
webContents.send('hermes:window-state-changed', state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nativeUiLocale: NativeMenuLocale = 'tr'
|
||||||
|
const nativeText = () => nativeDialogCopy(nativeUiLocale)
|
||||||
|
|
||||||
function buildApplicationMenu() {
|
function buildApplicationMenu() {
|
||||||
const template = []
|
const template = []
|
||||||
|
|
||||||
const checkForUpdatesItem = {
|
const checkForUpdatesItem = {
|
||||||
label: 'AITURK IDE güncellemeleri…',
|
label: 'Check for Updates…',
|
||||||
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
|
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6885,7 +6891,7 @@ function buildApplicationMenu() {
|
|||||||
submenu: [checkForUpdatesItem]
|
submenu: [checkForUpdatesItem]
|
||||||
})
|
})
|
||||||
|
|
||||||
return Menu.buildFromTemplate(template)
|
return Menu.buildFromTemplate(localizeNativeMenu(template, nativeUiLocale, APP_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDevTools(window) {
|
function toggleDevTools(window) {
|
||||||
@@ -7175,13 +7181,13 @@ function installDownloadHandling() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
item.setSaveDialogOptions({
|
item.setSaveDialogOptions({
|
||||||
title: 'Save File',
|
title: nativeText().saveFile,
|
||||||
defaultPath: path.join(app.getPath('downloads'), filename),
|
defaultPath: path.join(app.getPath('downloads'), filename),
|
||||||
filters:
|
filters:
|
||||||
extension || /^image\//i.test(item.getMimeType() || '')
|
extension || /^image\//i.test(item.getMimeType() || '')
|
||||||
? [
|
? [
|
||||||
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
|
{ name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
|
||||||
{ name: 'All Files', extensions: ['*'] }
|
{ name: nativeText().allFiles, extensions: ['*'] }
|
||||||
]
|
]
|
||||||
: undefined
|
: undefined
|
||||||
})
|
})
|
||||||
@@ -7534,7 +7540,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
|
|||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
width: 520,
|
width: 520,
|
||||||
height: 720,
|
height: 720,
|
||||||
title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway',
|
title: silent ? nativeText().cloudConnect : nativeText().gatewaySignIn,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
// Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in
|
// Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in
|
||||||
// well under a second, so the window normally never needs to show. We
|
// well under a second, so the window normally never needs to show. We
|
||||||
@@ -7963,7 +7969,7 @@ async function finalizeGatewayDownload(res, statusCode, headers, ctx: any = {})
|
|||||||
|
|
||||||
const result = await dialog.showSaveDialog(mainWindow, {
|
const result = await dialog.showSaveDialog(mainWindow, {
|
||||||
defaultPath: filename,
|
defaultPath: filename,
|
||||||
title: 'Save File'
|
title: nativeText().saveFile
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.canceled || !result.filePath) {
|
if (result.canceled || !result.filePath) {
|
||||||
@@ -8120,7 +8126,7 @@ async function saveGatewayFileViaDataUrl(
|
|||||||
|
|
||||||
const result = await dialog.showSaveDialog(mainWindow, {
|
const result = await dialog.showSaveDialog(mainWindow, {
|
||||||
defaultPath: filename,
|
defaultPath: filename,
|
||||||
title: 'Save File'
|
title: nativeText().saveFile
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.canceled || !result.filePath) {
|
if (result.canceled || !result.filePath) {
|
||||||
@@ -8412,7 +8418,7 @@ function renewPortalAccessSilently() {
|
|||||||
width: 520,
|
width: 520,
|
||||||
height: 720,
|
height: 720,
|
||||||
show: false,
|
show: false,
|
||||||
title: 'Renewing Hermes Cloud session…',
|
title: nativeText().cloudRenew,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -8517,7 +8523,7 @@ function openPortalLoginWindow() {
|
|||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
width: 520,
|
width: 520,
|
||||||
height: 720,
|
height: 720,
|
||||||
title: 'Sign in to Hermes Cloud',
|
title: nativeText().cloudSignIn,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -16763,7 +16769,7 @@ ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: options?.title || 'Add context',
|
title: options?.title || nativeText().addContext,
|
||||||
defaultPath: resolvedDefaultPath,
|
defaultPath: resolvedDefaultPath,
|
||||||
properties: properties as any,
|
properties: properties as any,
|
||||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||||
@@ -16786,7 +16792,7 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
|||||||
// elsewhere (the backend, for profile archives); this only picks the path.
|
// elsewhere (the backend, for profile archives); this only picks the path.
|
||||||
ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => {
|
ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => {
|
||||||
const result = await dialog.showSaveDialog(mainWindow, {
|
const result = await dialog.showSaveDialog(mainWindow, {
|
||||||
title: options?.title || 'Save',
|
title: options?.title || nativeText().save,
|
||||||
defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined,
|
defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined,
|
||||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||||
})
|
})
|
||||||
@@ -16954,6 +16960,20 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Language stays in backend display.language; this only updates native labels.
|
||||||
|
ipcMain.on('hermes:ui-language', (event, locale) => {
|
||||||
|
const next = resolveNativeMenuLocale(locale, event.sender.id, mainWindow?.webContents.id)
|
||||||
|
|
||||||
|
if (next === null || next === nativeUiLocale) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeUiLocale = next
|
||||||
|
if (IS_MAC) {
|
||||||
|
Menu.setApplicationMenu(buildApplicationMenu())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||||
ipcMain.on('hermes:native-theme', (_event, mode) => {
|
ipcMain.on('hermes:native-theme', (_event, mode) => {
|
||||||
if (!THEME_SOURCES.has(mode)) {
|
if (!THEME_SOURCES.has(mode)) {
|
||||||
@@ -17286,7 +17306,7 @@ ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
|||||||
|
|
||||||
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
title: 'Choose default project directory',
|
title: nativeText().chooseProjectDirectory,
|
||||||
properties: ['openDirectory', 'createDirectory'],
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
||||||
})
|
})
|
||||||
@@ -17846,7 +17866,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff)
|
const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff, nativeUiLocale)
|
||||||
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||||
|
|
||||||
if (!prompt || !parent || parent.isDestroyed()) {
|
if (!prompt || !parent || parent.isDestroyed()) {
|
||||||
@@ -17858,7 +17878,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
|
|||||||
|
|
||||||
void dialog
|
void dialog
|
||||||
.showMessageBox(parent, {
|
.showMessageBox(parent, {
|
||||||
buttons: ['Keep Running', 'Quit Anyway'],
|
buttons: [nativeText().keepRunning, nativeText().quitAnyway],
|
||||||
cancelId: 0,
|
cancelId: 0,
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
detail: prompt.detail,
|
detail: prompt.detail,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { NativeMenuLocale } from './native-menu-locale'
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
saveImage: 'Save image',
|
||||||
|
saveFile: 'Save file',
|
||||||
|
images: 'Images',
|
||||||
|
allFiles: 'All files',
|
||||||
|
addContext: 'Add context',
|
||||||
|
save: 'Save',
|
||||||
|
chooseProjectDirectory: 'Choose default project directory',
|
||||||
|
keepRunning: 'Keep running',
|
||||||
|
quitAnyway: 'Quit anyway',
|
||||||
|
update: 'AITURK IDE update',
|
||||||
|
updateMore: 'The update finished, but needs one more step',
|
||||||
|
updateFailed: 'AITURK IDE update did not finish',
|
||||||
|
details: 'Details',
|
||||||
|
cloudConnect: 'Connecting to the cloud agent…',
|
||||||
|
gatewaySignIn: 'Sign in to the gateway',
|
||||||
|
cloudRenew: 'Renewing the cloud session…',
|
||||||
|
cloudSignIn: 'Sign in to the cloud'
|
||||||
|
}
|
||||||
|
|
||||||
|
const tr: typeof en = {
|
||||||
|
saveImage: 'Görseli kaydet',
|
||||||
|
saveFile: 'Dosyayı kaydet',
|
||||||
|
images: 'Görseller',
|
||||||
|
allFiles: 'Tüm dosyalar',
|
||||||
|
addContext: 'Bağlam ekle',
|
||||||
|
save: 'Kaydet',
|
||||||
|
chooseProjectDirectory: 'Varsayılan proje klasörünü seçin',
|
||||||
|
keepRunning: 'Çalışmaya devam et',
|
||||||
|
quitAnyway: 'Yine de çık',
|
||||||
|
update: 'AITURK IDE güncellemesi',
|
||||||
|
updateMore: 'Güncelleme tamamlandı, ancak bir adım daha gerekiyor',
|
||||||
|
updateFailed: 'AITURK IDE güncellemesi tamamlanamadı',
|
||||||
|
details: 'Ayrıntılar',
|
||||||
|
cloudConnect: 'Bulut asistanına bağlanılıyor…',
|
||||||
|
gatewaySignIn: 'Ağ geçidine giriş yapın',
|
||||||
|
cloudRenew: 'Bulut oturumu yenileniyor…',
|
||||||
|
cloudSignIn: 'Bulut hesabına giriş yapın'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeDialogCopy(locale: NativeMenuLocale): typeof en {
|
||||||
|
return locale === 'tr' ? tr : en
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { MenuItemConstructorOptions } from 'electron'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { localizeNativeMenu, resolveNativeMenuLocale } from './native-menu-locale'
|
||||||
|
|
||||||
|
describe('native menu language', () => {
|
||||||
|
it('translates nested labels without changing actions, roles or keyboard ownership', () => {
|
||||||
|
const openFolder = vi.fn()
|
||||||
|
const zoom = vi.fn()
|
||||||
|
|
||||||
|
const template: MenuItemConstructorOptions[] = [
|
||||||
|
{
|
||||||
|
label: 'File',
|
||||||
|
submenu: [{ label: 'Open Folder…', click: openFolder }, { type: 'separator' }, { role: 'quit' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [{ label: 'Zoom In', accelerator: 'CommandOrControl+Plus', click: zoom }, { role: 'togglefullscreen' }]
|
||||||
|
},
|
||||||
|
{ label: 'My custom action', enabled: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
const translated = localizeNativeMenu(template, 'tr', 'AITURK IDE')
|
||||||
|
const file = translated[0].submenu as MenuItemConstructorOptions[]
|
||||||
|
const view = translated[1].submenu as MenuItemConstructorOptions[]
|
||||||
|
expect(translated[0].label).toBe('Dosya')
|
||||||
|
expect(file[0]).toEqual({ label: 'Klasör aç…', click: openFolder })
|
||||||
|
expect(file[1]).toEqual({ type: 'separator' })
|
||||||
|
expect(file[2]).toEqual({ role: 'quit', label: 'AITURK IDE uygulamasından çık' })
|
||||||
|
expect(view[0]).toEqual({ label: 'Yakınlaştır', accelerator: 'CommandOrControl+Plus', click: zoom })
|
||||||
|
expect(view[1].role).toBe('togglefullscreen')
|
||||||
|
expect(translated[2]).toEqual(template[2])
|
||||||
|
expect(localizeNativeMenu(template, 'en', 'AITURK IDE')).toBe(template)
|
||||||
|
expect((template[0].submenu as MenuItemConstructorOptions[])[0].label).toBe('Open Folder…')
|
||||||
|
expect(openFolder).not.toHaveBeenCalled()
|
||||||
|
expect(zoom).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts only supported language messages from the primary renderer', () => {
|
||||||
|
expect(resolveNativeMenuLocale('tr', 42, 42)).toBe('tr')
|
||||||
|
expect(resolveNativeMenuLocale('en', 42, 42)).toBe('en')
|
||||||
|
expect(resolveNativeMenuLocale('ja', 42, 42)).toBe('en')
|
||||||
|
|
||||||
|
for (const locale of ['tr', 'en']) {
|
||||||
|
expect(resolveNativeMenuLocale(locale, 43, 42)).toBeNull()
|
||||||
|
expect(resolveNativeMenuLocale(locale, 42, undefined)).toBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of [null, {}, ['tr'], 'unsupported', '__proto__']) {
|
||||||
|
expect(resolveNativeMenuLocale(value, 42, 42)).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { MenuItemConstructorOptions } from 'electron'
|
||||||
|
|
||||||
|
export type NativeMenuLocale = 'en' | 'tr'
|
||||||
|
|
||||||
|
// Only the primary settings surface owns the app-wide menu language. Utility
|
||||||
|
// windows have their own I18nProvider and must not overwrite that preference.
|
||||||
|
export function resolveNativeMenuLocale(
|
||||||
|
value: unknown,
|
||||||
|
senderId: number,
|
||||||
|
primaryId: number | undefined
|
||||||
|
): NativeMenuLocale | null {
|
||||||
|
if (primaryId === undefined || senderId !== primaryId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'tr') {
|
||||||
|
return 'tr'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && ['en', 'zh', 'zh-hant', 'ja', 'ar', 'ru'].includes(value)) {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeNativeMenu(
|
||||||
|
template: MenuItemConstructorOptions[],
|
||||||
|
locale: NativeMenuLocale,
|
||||||
|
appName: string
|
||||||
|
): MenuItemConstructorOptions[] {
|
||||||
|
if (locale !== 'tr') {
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
[`About ${appName}`]: `${appName} Hakkında`,
|
||||||
|
'Check for Updates…': 'Güncellemeleri kontrol et…',
|
||||||
|
File: 'Dosya',
|
||||||
|
'New Window': 'Yeni pencere',
|
||||||
|
'Open Folder…': 'Klasör aç…',
|
||||||
|
Close: 'Kapat',
|
||||||
|
Edit: 'Düzen',
|
||||||
|
View: 'Görünüm',
|
||||||
|
Reload: 'Yenile',
|
||||||
|
'Toggle Developer Tools': 'Geliştirici araçlarını aç/kapat',
|
||||||
|
'Actual Size': 'Gerçek boyut',
|
||||||
|
'Zoom In': 'Yakınlaştır',
|
||||||
|
'Zoom Out': 'Uzaklaştır',
|
||||||
|
Window: 'Pencere',
|
||||||
|
Help: 'Yardım'
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: Partial<Record<NonNullable<MenuItemConstructorOptions['role']>, string>> = {
|
||||||
|
services: 'Servisler',
|
||||||
|
hide: `${appName} uygulamasını gizle`,
|
||||||
|
hideOthers: 'Diğerlerini gizle',
|
||||||
|
unhide: 'Tümünü göster',
|
||||||
|
quit: `${appName} uygulamasından çık`,
|
||||||
|
undo: 'Geri al',
|
||||||
|
redo: 'Yinele',
|
||||||
|
cut: 'Kes',
|
||||||
|
copy: 'Kopyala',
|
||||||
|
paste: 'Yapıştır',
|
||||||
|
pasteAndMatchStyle: 'Biçimlendirmeden yapıştır',
|
||||||
|
delete: 'Sil',
|
||||||
|
selectAll: 'Tümünü seç',
|
||||||
|
forceReload: 'Uygulamayı zorla yenile',
|
||||||
|
togglefullscreen: 'Tam ekranı aç/kapat',
|
||||||
|
minimize: 'Simge durumuna küçült',
|
||||||
|
zoom: 'Pencereyi büyüt/küçült',
|
||||||
|
front: 'Tümünü öne getir',
|
||||||
|
close: 'Kapat'
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.map(item => {
|
||||||
|
const label =
|
||||||
|
item.label !== undefined
|
||||||
|
? Object.hasOwn(labels, item.label)
|
||||||
|
? labels[item.label]
|
||||||
|
: item.label
|
||||||
|
: item.role && roles[item.role]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...(label !== undefined && { label }),
|
||||||
|
...(Array.isArray(item.submenu) && { submenu: localizeNativeMenu(item.submenu, locale, appName) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true
|
|||||||
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||||
|
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
|
||||||
glassSupported: translucencySupport?.glass === true,
|
glassSupported: translucencySupport?.glass === true,
|
||||||
translucencySupported: translucencySupport?.translucency === true,
|
translucencySupported: translucencySupport?.translucency === true,
|
||||||
// Launch-flag fact: the app was started with --local, so the renderer may
|
// Launch-flag fact: the app was started with --local, so the renderer may
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ test('quitPromptFor names the running chats', () => {
|
|||||||
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
|
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
|
||||||
|
|
||||||
assert.ok(prompt)
|
assert.ok(prompt)
|
||||||
assert.equal(prompt.message, 'Hermes is still working on 2 chats.')
|
assert.equal(prompt.message, 'AITURK is still working on 2 chats.')
|
||||||
assert.ok(prompt.detail.includes('• Fix login'))
|
assert.ok(prompt.detail.includes('• Fix login'))
|
||||||
assert.ok(prompt.detail.includes('• Ship docs'))
|
assert.ok(prompt.detail.includes('• Ship docs'))
|
||||||
})
|
})
|
||||||
@@ -47,7 +47,7 @@ test('quitPromptFor summarizes past the list cap and counts untitled work', () =
|
|||||||
const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false)
|
const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false)
|
||||||
|
|
||||||
assert.ok(prompt)
|
assert.ok(prompt)
|
||||||
assert.equal(prompt.message, 'Hermes is still working on 9 chats.')
|
assert.equal(prompt.message, 'AITURK is still working on 9 chats.')
|
||||||
assert.ok(prompt.detail.includes('• d'))
|
assert.ok(prompt.detail.includes('• d'))
|
||||||
assert.ok(!prompt.detail.includes('• e'))
|
assert.ok(!prompt.detail.includes('• e'))
|
||||||
assert.ok(prompt.detail.includes('• 5 more'))
|
assert.ok(prompt.detail.includes('• 5 more'))
|
||||||
@@ -57,6 +57,20 @@ test('quitPromptFor speaks singular for one chat', () => {
|
|||||||
const prompt = quitPromptFor({ count: 1, titles: [] }, false)
|
const prompt = quitPromptFor({ count: 1, titles: [] }, false)
|
||||||
|
|
||||||
assert.ok(prompt)
|
assert.ok(prompt)
|
||||||
assert.equal(prompt.message, 'Hermes is still working on 1 chat.')
|
assert.equal(prompt.message, 'AITURK is still working on 1 chat.')
|
||||||
assert.ok(prompt.detail.includes('mid-turn'))
|
assert.ok(prompt.detail.includes('mid-turn'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('Turkish exit warning preserves custom titles, counts and handoff behavior', () => {
|
||||||
|
const work = { count: 7, titles: ['Custom English title', 'Türkçe görev'] }
|
||||||
|
const prompt = quitPromptFor(work, false, 'tr')
|
||||||
|
assert.ok(prompt)
|
||||||
|
assert.equal(prompt.message, 'AITURK hâlâ 7 sohbet üzerinde çalışıyor.')
|
||||||
|
assert.ok(prompt.detail.includes('• Custom English title'))
|
||||||
|
assert.ok(prompt.detail.includes('• Türkçe görev'))
|
||||||
|
assert.ok(prompt.detail.includes('• 5 sohbet daha'))
|
||||||
|
assert.ok(prompt.detail.includes('çalışmasını durdurur'))
|
||||||
|
assert.equal(quitPromptFor(work, true, 'tr'), null)
|
||||||
|
assert.equal(quitPromptFor({ count: 0, titles: [] }, false, 'tr'), null)
|
||||||
|
})
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export interface QuitPrompt {
|
|||||||
* are the app replacing itself, not the user walking away, and a modal there
|
* are the app replacing itself, not the user walking away, and a modal there
|
||||||
* would strand the detached script waiting on a PID that never exits.
|
* would strand the detached script waiting on a PID that never exits.
|
||||||
*/
|
*/
|
||||||
export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt {
|
export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean, locale: 'en' | 'tr' = 'en'): null | QuitPrompt {
|
||||||
if (quittingForHandoff || work.count < 1) {
|
if (quittingForHandoff || work.count < 1) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -75,18 +75,22 @@ export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): nu
|
|||||||
const lines = listed.map(title => `• ${title}`)
|
const lines = listed.map(title => `• ${title}`)
|
||||||
|
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
lines.push(remaining === 1 ? '• 1 more' : `• ${remaining} more`)
|
lines.push(locale === 'tr' ? `• ${remaining} sohbet daha` : remaining === 1 ? '• 1 more' : `• ${remaining} more`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
detail: [
|
detail: [
|
||||||
lines.join('\n'),
|
lines.join('\n'),
|
||||||
lines.length > 0 ? '' : null,
|
lines.length > 0 ? '' : null,
|
||||||
'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.'
|
locale === 'tr'
|
||||||
|
? 'Çıkış yapmak asistanın devam eden çalışmasını durdurur. Henüz yazmayı tamamlamadığı çalışma kaybolur.'
|
||||||
|
: 'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.'
|
||||||
]
|
]
|
||||||
.filter(line => line !== null)
|
.filter(line => line !== null)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim(),
|
.trim(),
|
||||||
message: work.count === 1 ? 'Hermes is still working on 1 chat.' : `Hermes is still working on ${work.count} chats.`
|
message: locale === 'tr'
|
||||||
|
? `AITURK hâlâ ${work.count} sohbet üzerinde çalışıyor.`
|
||||||
|
: work.count === 1 ? 'AITURK is still working on 1 chat.' : `AITURK is still working on ${work.count} chats.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.6",
|
||||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -35,6 +35,8 @@
|
|||||||
"pack": "npm run build && npm run builder -- --dir --publish never",
|
"pack": "npm run build && npm run builder -- --dir --publish never",
|
||||||
"dist": "npm run build && npm run builder",
|
"dist": "npm run build && npm run builder",
|
||||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||||
|
"dist:mac:arm64": "npm run build && npm run builder -- --mac --arm64",
|
||||||
|
"dist:mac:x64": "npm run build && npm run builder -- --mac --x64",
|
||||||
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
|
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
|
||||||
"dist:mac:zip": "npm run build && npm run builder -- --mac zip",
|
"dist:mac:zip": "npm run build && npm run builder -- --mac zip",
|
||||||
"dist:win": "npm run build && npm run builder -- --win",
|
"dist:win": "npm run build && npm run builder -- --win",
|
||||||
@@ -229,6 +231,12 @@
|
|||||||
"dist/**"
|
"dist/**"
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "assets/mac/tr.lproj/InfoPlist.strings",
|
||||||
|
"to": "tr.lproj/InfoPlist.strings"
|
||||||
|
}
|
||||||
|
],
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"entitlements": "electron/entitlements.mac.plist",
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
|
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const PLATFORM = process.platform
|
|||||||
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
|
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
|
||||||
const APP = (() => {
|
const APP = (() => {
|
||||||
if (PLATFORM === 'darwin') {
|
if (PLATFORM === 'darwin') {
|
||||||
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
|
const appPath = path.join(RELEASE_ROOT, ARCH === 'x64' ? 'mac' : `mac-${ARCH}`, `${PACKAGE_JSON.build.executableName}.app`)
|
||||||
return {
|
return {
|
||||||
appPath,
|
appPath,
|
||||||
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'),
|
binary: path.join(appPath, 'Contents', 'MacOS', PACKAGE_JSON.build.executableName),
|
||||||
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
|
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
|
||||||
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
|
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
|
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||||
@@ -32,7 +32,7 @@ const APP = (() => {
|
|||||||
const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
|
const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
|
||||||
return {
|
return {
|
||||||
appPath: unpacked,
|
appPath: unpacked,
|
||||||
binary: path.join(unpacked, 'Hermes.exe'),
|
binary: path.join(unpacked, `${PACKAGE_JSON.build.executableName}.exe`),
|
||||||
resourcesPath: path.join(unpacked, 'resources'),
|
resourcesPath: path.join(unpacked, 'resources'),
|
||||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||||
@@ -42,24 +42,24 @@ const APP = (() => {
|
|||||||
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
|
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
|
||||||
return {
|
return {
|
||||||
appPath: unpacked,
|
appPath: unpacked,
|
||||||
binary: path.join(unpacked, 'Hermes'),
|
binary: path.join(unpacked, PACKAGE_JSON.build.executableName),
|
||||||
resourcesPath: path.join(unpacked, 'resources'),
|
resourcesPath: path.join(unpacked, 'resources'),
|
||||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
|
// Match AITURK's product-owned runtime; never inspect another Hermes install.
|
||||||
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
|
const DEFAULT_AGENT_HOME = (() => {
|
||||||
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
|
if (process.env.AITURK_IDE_HOME) return path.resolve(process.env.AITURK_IDE_HOME)
|
||||||
// HERMES_HOME and never touches this.
|
const userData = process.env.AITURK_IDE_USER_DATA_DIR || process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||||||
const DEFAULT_HERMES_HOME = (() => {
|
if (userData) return path.join(path.resolve(userData), 'agent-home')
|
||||||
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
|
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
|
||||||
return path.join(process.env.LOCALAPPDATA, 'hermes')
|
return path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE', 'agent')
|
||||||
}
|
}
|
||||||
return path.join(os.homedir(), '.hermes')
|
return path.join(os.homedir(), '.aiturk-ide', 'agent')
|
||||||
})()
|
})()
|
||||||
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
|
const VENV_ROOT = path.join(DEFAULT_AGENT_HOME, 'hermes-agent', 'venv')
|
||||||
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
|
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
|
||||||
|
|
||||||
function die(message) {
|
function die(message) {
|
||||||
@@ -124,10 +124,10 @@ function ensurePackagedApp() {
|
|||||||
|
|
||||||
function resolveDmgPath() {
|
function resolveDmgPath() {
|
||||||
if (!exists(RELEASE_ROOT)) {
|
if (!exists(RELEASE_ROOT)) {
|
||||||
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
return path.join(RELEASE_ROOT, `AITURK-IDE-${PACKAGE_JSON.version}-mac-${ARCH}.dmg`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = `Hermes-${PACKAGE_JSON.version}`
|
const prefix = `AITURK-IDE-${PACKAGE_JSON.version}-mac-`
|
||||||
const candidates = fs
|
const candidates = fs
|
||||||
.readdirSync(RELEASE_ROOT)
|
.readdirSync(RELEASE_ROOT)
|
||||||
.filter(name => name.endsWith('.dmg'))
|
.filter(name => name.endsWith('.dmg'))
|
||||||
@@ -141,15 +141,15 @@ function resolveDmgPath() {
|
|||||||
|
|
||||||
return candidates.length > 0
|
return candidates.length > 0
|
||||||
? path.join(RELEASE_ROOT, candidates[0])
|
? path.join(RELEASE_ROOT, candidates[0])
|
||||||
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
: path.join(RELEASE_ROOT, `AITURK-IDE-${PACKAGE_JSON.version}-mac-${ARCH}.dmg`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNsisPath() {
|
function resolveNsisPath() {
|
||||||
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
|
// Select only this AITURK release and architecture.
|
||||||
if (!exists(RELEASE_ROOT)) return null
|
if (!exists(RELEASE_ROOT)) return null
|
||||||
const candidates = fs
|
const candidates = fs
|
||||||
.readdirSync(RELEASE_ROOT)
|
.readdirSync(RELEASE_ROOT)
|
||||||
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
|
.filter(name => name === `AITURK-IDE-${PACKAGE_JSON.version}-win-${ARCH}.exe`)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
||||||
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
||||||
@@ -261,6 +261,9 @@ function launchFresh() {
|
|||||||
env.HERMES_DESKTOP_TEST_MODE = 'fresh-install'
|
env.HERMES_DESKTOP_TEST_MODE = 'fresh-install'
|
||||||
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
|
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
|
||||||
env.HERMES_HOME = hermesHome
|
env.HERMES_HOME = hermesHome
|
||||||
|
env.AITURK_IDE_HOME = hermesHome
|
||||||
|
env.AITURK_IDE_USER_DATA_DIR = userDataDir
|
||||||
|
delete env.HERMES_DESKTOP_PYTHON
|
||||||
delete env.HERMES_DESKTOP_HERMES
|
delete env.HERMES_DESKTOP_HERMES
|
||||||
delete env.HERMES_DESKTOP_HERMES_ROOT
|
delete env.HERMES_DESKTOP_HERMES_ROOT
|
||||||
|
|
||||||
@@ -275,7 +278,7 @@ function launchFresh() {
|
|||||||
console.log('\nFresh install sandbox:')
|
console.log('\nFresh install sandbox:')
|
||||||
console.log(` root: ${sandbox}`)
|
console.log(` root: ${sandbox}`)
|
||||||
console.log(` electron userData: ${userDataDir}`)
|
console.log(` electron userData: ${userDataDir}`)
|
||||||
console.log(` HERMES_HOME: ${hermesHome}`)
|
console.log(` AITURK_IDE_HOME: ${hermesHome}`)
|
||||||
console.log(` cwd: ${cwd}`)
|
console.log(` cwd: ${cwd}`)
|
||||||
|
|
||||||
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
|
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
|
||||||
@@ -399,8 +402,8 @@ function printArtifacts(options = {}) {
|
|||||||
|
|
||||||
function help() {
|
function help() {
|
||||||
console.log(`Usage:
|
console.log(`Usage:
|
||||||
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
|
npm run test:desktop:existing # build packaged app, launch with the existing AITURK profile
|
||||||
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
|
npm run test:desktop:fresh # build packaged app, launch with temp userData + AITURK_IDE_HOME
|
||||||
npm run test:desktop:dmg # (macOS only) build DMG and open it
|
npm run test:desktop:dmg # (macOS only) build DMG and open it
|
||||||
npm run test:desktop:nsis # (win32 only) build NSIS installer
|
npm run test:desktop:nsis # (win32 only) build NSIS installer
|
||||||
npm run test:desktop:all # build installer, validate app payload, print paths
|
npm run test:desktop:all # build installer, validate app payload, print paths
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export function VoicePlaybackActivity() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<VolumeX className={iconSize.xs} />
|
<VolumeX className={iconSize.xs} />
|
||||||
Stop
|
{t.surfaces.stop}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { lazy, type ReactNode, Suspense } from 'react'
|
|||||||
|
|
||||||
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
||||||
import { useContributions } from '@/contrib/react/use-contributions'
|
import { useContributions } from '@/contrib/react/use-contributions'
|
||||||
|
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||||
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
|
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
|
||||||
|
|
||||||
import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes'
|
import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes'
|
||||||
@@ -22,10 +23,10 @@ const MessagingView = lazy(async () => ({ default: (await import('../messaging')
|
|||||||
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
|
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
|
||||||
|
|
||||||
// Built-in page views + their pane titles, keyed by route.
|
// Built-in page views + their pane titles, keyed by route.
|
||||||
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = {
|
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; titleKey: keyof Translations['sidebar']['nav'] }> = {
|
||||||
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' },
|
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, titleKey: 'artifacts' },
|
||||||
[MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' },
|
[MESSAGING_ROUTE]: { render: () => <MessagingView />, titleKey: 'messaging' },
|
||||||
[SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' }
|
[SKILLS_ROUTE]: { render: () => <SkillsView />, titleKey: 'skills' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */
|
/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */
|
||||||
@@ -41,13 +42,21 @@ const humanizePath = (path: string): string =>
|
|||||||
* else a humanized path — never the internal `${source}:${id}` key. */
|
* else a humanized path — never the internal `${source}:${id}` key. */
|
||||||
function routeTitle(path: string): string {
|
function routeTitle(path: string): string {
|
||||||
if (BUILTIN_PAGES[path]) {
|
if (BUILTIN_PAGES[path]) {
|
||||||
return BUILTIN_PAGES[path].title
|
return translateNow(`sidebar.nav.${BUILTIN_PAGES[path].titleKey}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path)
|
return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteTileTitle({ path }: { path: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const builtin = BUILTIN_PAGES[path]
|
||||||
|
|
||||||
|
return <>{builtin ? t.sidebar.nav[builtin.titleKey] : routeTitle(path)}</>
|
||||||
|
}
|
||||||
|
|
||||||
function RouteTilePane({ path }: { path: string }) {
|
function RouteTilePane({ path }: { path: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const builtin = BUILTIN_PAGES[path]
|
const builtin = BUILTIN_PAGES[path]
|
||||||
|
|
||||||
// Subscribe so a plugin page tile appears the moment its route registers.
|
// Subscribe so a plugin page tile appears the moment its route registers.
|
||||||
@@ -74,7 +83,7 @@ function RouteTilePane({ path }: { path: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center font-mono text-[11px] text-(--ui-text-quaternary)">
|
<div className="grid h-full place-items-center font-mono text-[11px] text-(--ui-text-quaternary)">
|
||||||
no page at {path}
|
{t.surfaces.noPage(path)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,6 +100,7 @@ export const watchRouteTiles = paneMirror<RouteTile>({
|
|||||||
dir: t => t.dir,
|
dir: t => t.dir,
|
||||||
minWidth: '22rem',
|
minWidth: '22rem',
|
||||||
title: routeTitle,
|
title: routeTitle,
|
||||||
|
tabTitle: path => <RouteTileTitle path={path} />,
|
||||||
render: path => <RouteTilePane path={path} />,
|
render: path => <RouteTilePane path={path} />,
|
||||||
close: closeRouteTile
|
close: closeRouteTile
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ function TileChat({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
|
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const tiles = useStore($sessionTiles)
|
const tiles = useStore($sessionTiles)
|
||||||
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
|
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
|
||||||
const ownerRoute = tile?.ownerRoute
|
const ownerRoute = tile?.ownerRoute
|
||||||
@@ -462,10 +463,10 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
|
|||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-4">
|
<div className="grid h-full place-items-center p-4">
|
||||||
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
|
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
|
||||||
<div className="text-(--ui-danger,#f87171)">Couldn't open this session</div>
|
<div className="text-(--ui-danger,#f87171)">{t.surfaces.sessionUnavailable}</div>
|
||||||
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
|
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
|
||||||
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
|
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
|
||||||
Retry
|
{t.common.retry}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,47 +72,6 @@ interface Option<T extends string = string> {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUPINGS: Option<SidebarGrouping>[] = [
|
|
||||||
{ icon: 'clock', id: 'date', label: 'Updated' },
|
|
||||||
{ icon: 'root-folder', id: 'project', label: 'Project' },
|
|
||||||
{ icon: 'pulse', id: 'status', label: 'Status' },
|
|
||||||
{ icon: 'account', id: 'profile', label: 'Profile' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const ORDERINGS: Option<SidebarOrdering>[] = [
|
|
||||||
{ icon: 'clock', id: 'updated', label: 'Updated' },
|
|
||||||
{ icon: 'add', id: 'created', label: 'Created' },
|
|
||||||
{ icon: 'pulse', id: 'status', label: 'Status' },
|
|
||||||
{ icon: 'symbol-numeric', id: 'tokens', label: 'Tokens' },
|
|
||||||
{ icon: 'credit-card', id: 'cost', label: 'Cost' },
|
|
||||||
{ icon: 'list-ordered', id: 'manual', label: 'Manual' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const ROW_META: Option<SidebarRowMeta>[] = [
|
|
||||||
{ icon: 'clock', id: 'updated', label: 'Updated' },
|
|
||||||
{ icon: 'comment', id: 'preview', label: 'Preview' },
|
|
||||||
{ icon: 'symbol-numeric', id: 'tokens', label: 'Tokens' },
|
|
||||||
{ icon: 'credit-card', id: 'cost', label: 'Cost' },
|
|
||||||
{ icon: 'git-pull-request', id: 'pr', label: 'PR' },
|
|
||||||
{ icon: 'account', id: 'profile', label: 'Profile' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const PR_FILTERS: Option<PullRequestBucket>[] = [
|
|
||||||
{ icon: 'git-pull-request', id: 'open', label: 'Open' },
|
|
||||||
{ icon: 'git-pull-request-draft', id: 'draft', label: 'Draft' },
|
|
||||||
{ icon: 'git-merge', id: 'merged', label: 'Merged' },
|
|
||||||
{ icon: 'git-pull-request-closed', id: 'closed', label: 'Closed' },
|
|
||||||
{ icon: 'circle-slash', id: 'none', label: 'No PR' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const STATUS_FILTERS: Option<SessionStatusBucket>[] = [
|
|
||||||
{ dot: sessionDotClassName('needs-input'), id: 'needs-input', label: 'Needs input' },
|
|
||||||
{ dot: sessionDotClassName('working'), id: 'working', label: 'Working' },
|
|
||||||
{ dot: sessionDotClassName('unread'), id: 'unread', label: 'Unread' },
|
|
||||||
{ dot: sessionDotClassName('draft'), id: 'draft', label: 'Draft' },
|
|
||||||
{ dot: cn(sessionDotClassName('idle'), 'bg-(--ui-text-quaternary)'), id: 'idle', label: 'Idle' }
|
|
||||||
]
|
|
||||||
|
|
||||||
function OptionGlyph({ option }: { option: Option }) {
|
function OptionGlyph({ option }: { option: Option }) {
|
||||||
if (option.dot) {
|
if (option.dot) {
|
||||||
return <span aria-hidden="true" className={cn('shrink-0', option.dot)} />
|
return <span aria-hidden="true" className={cn('shrink-0', option.dot)} />
|
||||||
@@ -121,7 +80,7 @@ function OptionGlyph({ option }: { option: Option }) {
|
|||||||
return option.icon ? <Codicon className="text-(--ui-text-tertiary)" name={option.icon} size="0.8125rem" /> : null
|
return option.icon ? <Codicon className="text-(--ui-text-tertiary)" name={option.icon} size="0.8125rem" /> : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every option row — single or multi select — leaves the menu open, so a whole
|
/** Every option row — single or multi select — leaves the menu open, so a whole
|
||||||
* view can be set up in one pass. Only the actions at the bottom dismiss it. */
|
* view can be set up in one pass. Only the actions at the bottom dismiss it. */
|
||||||
const keepOpen = (event: Event) => event.preventDefault()
|
const keepOpen = (event: Event) => event.preventDefault()
|
||||||
|
|
||||||
@@ -151,6 +110,48 @@ function OptionRadio({ option }: { option: Option }) {
|
|||||||
|
|
||||||
export function SidebarFilterMenu({ className }: { className?: string }) {
|
export function SidebarFilterMenu({ className }: { className?: string }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const GROUPINGS: Option<SidebarGrouping>[] = [
|
||||||
|
{ icon: 'clock', id: 'date', label: t.surfaces.filters.updated },
|
||||||
|
{ icon: 'root-folder', id: 'project', label: t.surfaces.filters.project },
|
||||||
|
{ icon: 'pulse', id: 'status', label: t.surfaces.filters.status },
|
||||||
|
{ icon: 'account', id: 'profile', label: t.surfaces.filters.profile }
|
||||||
|
]
|
||||||
|
|
||||||
|
const ORDERINGS: Option<SidebarOrdering>[] = [
|
||||||
|
{ icon: 'clock', id: 'updated', label: t.surfaces.filters.updated },
|
||||||
|
{ icon: 'add', id: 'created', label: t.surfaces.filters.created },
|
||||||
|
{ icon: 'pulse', id: 'status', label: t.surfaces.filters.status },
|
||||||
|
{ icon: 'symbol-numeric', id: 'tokens', label: t.surfaces.filters.tokens },
|
||||||
|
{ icon: 'credit-card', id: 'cost', label: t.surfaces.filters.cost },
|
||||||
|
{ icon: 'list-ordered', id: 'manual', label: t.surfaces.filters.manual }
|
||||||
|
]
|
||||||
|
|
||||||
|
const ROW_META: Option<SidebarRowMeta>[] = [
|
||||||
|
{ icon: 'clock', id: 'updated', label: t.surfaces.filters.updated },
|
||||||
|
{ icon: 'comment', id: 'preview', label: t.surfaces.filters.preview },
|
||||||
|
{ icon: 'symbol-numeric', id: 'tokens', label: t.surfaces.filters.tokens },
|
||||||
|
{ icon: 'credit-card', id: 'cost', label: t.surfaces.filters.cost },
|
||||||
|
{ icon: 'git-pull-request', id: 'pr', label: t.surfaces.filters.pr },
|
||||||
|
{ icon: 'account', id: 'profile', label: t.surfaces.filters.profile }
|
||||||
|
]
|
||||||
|
|
||||||
|
const PR_FILTERS: Option<PullRequestBucket>[] = [
|
||||||
|
{ icon: 'git-pull-request', id: 'open', label: t.surfaces.filters.open },
|
||||||
|
{ icon: 'git-pull-request-draft', id: 'draft', label: t.surfaces.filters.draft },
|
||||||
|
{ icon: 'git-merge', id: 'merged', label: t.surfaces.filters.merged },
|
||||||
|
{ icon: 'git-pull-request-closed', id: 'closed', label: t.surfaces.filters.closed },
|
||||||
|
{ icon: 'circle-slash', id: 'none', label: t.surfaces.filters.noPr }
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_FILTERS: Option<SessionStatusBucket>[] = [
|
||||||
|
{ dot: sessionDotClassName('needs-input'), id: 'needs-input', label: t.surfaces.filters.needsInput },
|
||||||
|
{ dot: sessionDotClassName('working'), id: 'working', label: t.surfaces.filters.working },
|
||||||
|
{ dot: sessionDotClassName('unread'), id: 'unread', label: t.surfaces.filters.unread },
|
||||||
|
{ dot: sessionDotClassName('draft'), id: 'draft', label: t.surfaces.filters.draft },
|
||||||
|
{ dot: cn(sessionDotClassName('idle'), 'bg-(--ui-text-quaternary)'), id: 'idle', label: t.surfaces.filters.idle }
|
||||||
|
]
|
||||||
|
|
||||||
const grouping = useStore($sidebarGrouping)
|
const grouping = useStore($sidebarGrouping)
|
||||||
const ordering = useStore($sidebarOrdering)
|
const ordering = useStore($sidebarOrdering)
|
||||||
const rowMeta = useStore($sidebarRowMeta)
|
const rowMeta = useStore($sidebarRowMeta)
|
||||||
@@ -170,7 +171,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
const projects = useStore($projectTree)
|
const projects = useStore($projectTree)
|
||||||
const hasCost = useStore($sessionsHaveCost)
|
const hasCost = useStore($sessionsHaveCost)
|
||||||
const unreadIds = useStore($unreadFinishedSessionIds)
|
const unreadIds = useStore($unreadFinishedSessionIds)
|
||||||
// PR state comes from `gh` on whichever machine holds the checkout — Electron
|
// PR state comes from `gh` on whichever machine holds the checkout — Electron
|
||||||
// locally, the gateway's REST mirror remotely. Resolved per render, not once
|
// locally, the gateway's REST mirror remotely. Resolved per render, not once
|
||||||
// at module load: switching to a remote profile swaps the bridge underneath.
|
// at module load: switching to a remote profile swaps the bridge underneath.
|
||||||
const prAvailable = Boolean(desktopGit()?.review?.prList)
|
const prAvailable = Boolean(desktopGit()?.review?.prList)
|
||||||
@@ -217,12 +218,12 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Filters"
|
aria-label={t.surfaces.filters.filters}
|
||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100',
|
'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100',
|
||||||
// Active filters read as "this control is engaged", the same way the
|
// Active filters read as "this control is engaged", the same way the
|
||||||
// open menu does — never as an accent, which the sidebar reserves
|
// open menu does — never as an accent, which the sidebar reserves
|
||||||
// for a session that is actually doing something.
|
// for a session that is actually doing something.
|
||||||
filtersActive && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
filtersActive && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||||
)}
|
)}
|
||||||
@@ -238,7 +239,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger hideChevron>
|
<DropdownMenuSubTrigger hideChevron>
|
||||||
Grouping
|
{t.surfaces.filters.grouping}
|
||||||
<span className="ml-auto flex items-center gap-1 pl-4 text-(--ui-text-tertiary)">
|
<span className="ml-auto flex items-center gap-1 pl-4 text-(--ui-text-tertiary)">
|
||||||
{groupingLabel}
|
{groupingLabel}
|
||||||
<Codicon name="chevron-right" size="1rem" />
|
<Codicon name="chevron-right" size="1rem" />
|
||||||
@@ -257,7 +258,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Ordering</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>{t.surfaces.filters.ordering}</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
onValueChange={value => setSidebarOrdering(value as SidebarOrdering)}
|
onValueChange={value => setSidebarOrdering(value as SidebarOrdering)}
|
||||||
@@ -271,7 +272,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Show</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>{t.surfaces.filters.show}</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{rowMetaOptions.map(option => (
|
{rowMetaOptions.map(option => (
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
@@ -284,22 +285,22 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
{/* A render variant, not a grouping: three-line cards (project · age /
|
{/* A render variant, not a grouping: three-line cards (project · age /
|
||||||
title / model · size) compose with whichever grouping is active. */}
|
title / model · size) compose with whichever grouping is active. */}
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
checked={cardRows}
|
checked={cardRows}
|
||||||
onCheck={() => setSidebarCardRows(!cardRows)}
|
onCheck={() => setSidebarCardRows(!cardRows)}
|
||||||
option={{ icon: 'inbox', id: 'card-rows', label: 'Inbox style' }}
|
option={{ icon: 'inbox', id: 'card-rows', label: t.surfaces.filters.inbox }}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel>Filters</DropdownMenuLabel>
|
<DropdownMenuLabel>{t.surfaces.filters.filters}</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>{t.surfaces.filters.status}</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{STATUS_FILTERS.map(option => (
|
{STATUS_FILTERS.map(option => (
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
@@ -316,7 +317,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
this submenu never appears rather than filtering everything out. */}
|
this submenu never appears rather than filtering everything out. */}
|
||||||
{prAvailable && (
|
{prAvailable && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Pull request</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>{t.surfaces.filters.pullRequest}</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{PR_FILTERS.map(option => (
|
{PR_FILTERS.map(option => (
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
@@ -331,7 +332,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Profile</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>{t.surfaces.filters.profile}</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent className="max-h-80 overflow-y-auto">
|
<DropdownMenuSubContent className="max-h-80 overflow-y-auto">
|
||||||
{/* Scoped to one profile the rail is already the filter, so the
|
{/* Scoped to one profile the rail is already the filter, so the
|
||||||
per-profile boxes only appear where they can narrow something.
|
per-profile boxes only appear where they can narrow something.
|
||||||
@@ -358,7 +359,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
|
|
||||||
{projects.length > 1 && (
|
{projects.length > 1 && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>Project</DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>{t.surfaces.filters.project}</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent className="max-h-80 overflow-y-auto">
|
<DropdownMenuSubContent className="max-h-80 overflow-y-auto">
|
||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
@@ -378,7 +379,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Off by default: one profile's sessions are what the rail selected.
|
{/* Off by default: one profile's sessions are what the rail selected.
|
||||||
Nothing to widen to until a second profile exists — but stay
|
Nothing to widen to until a second profile exists — but stay
|
||||||
visible while it's on, or deleting your way back down to one
|
visible while it's on, or deleting your way back down to one
|
||||||
profile would strand the sidebar in a mode nothing can leave (the
|
profile would strand the sidebar in a mode nothing can leave (the
|
||||||
rail hides its switcher at one profile too). */}
|
rail hides its switcher at one profile too). */}
|
||||||
@@ -393,23 +394,25 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
<OptionCheckbox
|
<OptionCheckbox
|
||||||
checked={showArchived}
|
checked={showArchived}
|
||||||
onCheck={() => setSidebarShowArchived(!showArchived)}
|
onCheck={() => setSidebarShowArchived(!showArchived)}
|
||||||
option={{ id: 'archived', label: 'Archived' }}
|
option={{ id: 'archived', label: t.surfaces.filters.archived }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* One way back rather than two near-identical ones: this drops the
|
{/* One way back rather than two near-identical ones: this drops the
|
||||||
grouping and sort too, which "clear filters" left behind. */}
|
grouping and sort too, which "clear filters" left behind. */}
|
||||||
{viewCustomized && <DropdownMenuItem onSelect={resetSidebarView}>Reset to defaults</DropdownMenuItem>}
|
{viewCustomized && (
|
||||||
|
<DropdownMenuItem onSelect={resetSidebarView}>{t.surfaces.filters.reset}</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
{foldIds.length > 0 && (
|
{foldIds.length > 0 && (
|
||||||
<DropdownMenuItem onSelect={() => setWorkspaceNodesOpen(foldIds, foldCollapsed)}>
|
<DropdownMenuItem onSelect={() => setWorkspaceNodesOpen(foldIds, foldCollapsed)}>
|
||||||
{foldCollapsed ? 'Expand all' : 'Collapse all'}
|
{foldCollapsed ? t.surfaces.filters.expand : t.surfaces.filters.collapse}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem disabled={unreadIds.length === 0} onSelect={markAllSessionsRead}>
|
<DropdownMenuItem disabled={unreadIds.length === 0} onSelect={markAllSessionsRead}>
|
||||||
Mark all as read
|
{t.surfaces.filters.markRead}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { setPetActivity } from '@/store/pet'
|
import { setPetActivity } from '@/store/pet'
|
||||||
import { setPetScale } from '@/store/pet-gallery'
|
import { setPetScale } from '@/store/pet-gallery'
|
||||||
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
|
import {
|
||||||
|
setPetOverlayLocale,
|
||||||
|
setPetOverlayOpenAppHandler,
|
||||||
|
setPetOverlayScaleHandler,
|
||||||
|
setPetOverlaySubmitHandler
|
||||||
|
} from '@/store/pet-overlay'
|
||||||
import { $sessions } from '@/store/session'
|
import { $sessions } from '@/store/session'
|
||||||
import { $attentionSessionIds } from '@/store/session-states'
|
import { $attentionSessionIds } from '@/store/session-states'
|
||||||
import { isAuxiliaryWindow } from '@/store/windows'
|
import { isAuxiliaryWindow } from '@/store/windows'
|
||||||
@@ -23,6 +29,12 @@ interface PetBridgeParams {
|
|||||||
* window that can drop a submit. Primary window only.
|
* window that can drop a submit. Primary window only.
|
||||||
*/
|
*/
|
||||||
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
|
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
|
||||||
|
const { locale } = useI18n()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuxiliaryWindow()) {
|
||||||
|
setPetOverlayLocale(locale)
|
||||||
|
}
|
||||||
|
}, [locale])
|
||||||
const submitTextRef = useRef(submitText)
|
const submitTextRef = useRef(submitText)
|
||||||
submitTextRef.current = submitText
|
submitTextRef.current = submitText
|
||||||
const resumeSessionRef = useRef(resumeSession)
|
const resumeSessionRef = useRef(resumeSession)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import {
|
import {
|
||||||
initQuickEntryBridge,
|
initQuickEntryBridge,
|
||||||
QUICK_TARGET_CURRENT,
|
QUICK_TARGET_CURRENT,
|
||||||
@@ -51,6 +52,7 @@ function sessionOptions(): QuickEntrySessionOption[] {
|
|||||||
* one keystroke would send N prompts.
|
* one keystroke would send N prompts.
|
||||||
*/
|
*/
|
||||||
export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: QuickEntryBridgeParams): void {
|
export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: QuickEntryBridgeParams): void {
|
||||||
|
const { locale } = useI18n()
|
||||||
const submitTextRef = useRef(submitText)
|
const submitTextRef = useRef(submitText)
|
||||||
submitTextRef.current = submitText
|
submitTextRef.current = submitText
|
||||||
const startFreshRef = useRef(startFreshSessionDraft)
|
const startFreshRef = useRef(startFreshSessionDraft)
|
||||||
@@ -112,7 +114,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
|
|||||||
}
|
}
|
||||||
|
|
||||||
const push = () => {
|
const push = () => {
|
||||||
api.pushState({ connected: $gatewayState.get() === 'open', sessions: sessionOptions() })
|
api.pushState({ connected: $gatewayState.get() === 'open', locale, sessions: sessionOptions() })
|
||||||
}
|
}
|
||||||
|
|
||||||
push()
|
push()
|
||||||
@@ -124,5 +126,5 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
|
|||||||
offGateway()
|
offGateway()
|
||||||
offSessions()
|
offSessions()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [locale])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
|||||||
import { useContributions } from '@/contrib/react/use-contributions'
|
import { useContributions } from '@/contrib/react/use-contributions'
|
||||||
import { registry } from '@/contrib/registry'
|
import { registry } from '@/contrib/registry'
|
||||||
import { getLogs } from '@/hermes'
|
import { getLogs } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { openPreview } from '@/store/preview'
|
import { openPreview } from '@/store/preview'
|
||||||
@@ -34,6 +35,8 @@ import { $currentCwd } from '@/store/session'
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function LogsPane() {
|
export function LogsPane() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { data, error } = useQuery({
|
const { data, error } = useQuery({
|
||||||
queryKey: ['contrib-logs-tail'],
|
queryKey: ['contrib-logs-tail'],
|
||||||
queryFn: () => getLogs({ lines: 300 }),
|
queryFn: () => getLogs({ lines: 300 }),
|
||||||
@@ -41,7 +44,7 @@ export function LogsPane() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="p-3 text-xs text-(--ui-text-quaternary)">log unavailable: {String(error)}</div>
|
return <div className="p-3 text-xs text-(--ui-text-quaternary)">{t.surfaces.logUnavailable(String(error))}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { ExternalLink } from '@/lib/external-link'
|
import { ExternalLink } from '@/lib/external-link'
|
||||||
import { PawPrint, Settings2 } from '@/lib/icons'
|
import { PawPrint, Settings2 } from '@/lib/icons'
|
||||||
|
|
||||||
@@ -10,25 +11,25 @@ interface GenerateUnavailableProps {
|
|||||||
// impossible, so we replace the prompt entirely with a friendly path to set one
|
// impossible, so we replace the prompt entirely with a friendly path to set one
|
||||||
// up (in-app) plus where to grab a key.
|
// up (in-app) plus where to grab a key.
|
||||||
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
|
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
|
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
|
||||||
<PawPrint className="size-5" />
|
<PawPrint className="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">{t.surfaces.imageBackendTitle}</p>
|
||||||
Add an image backend to generate
|
|
||||||
</p>
|
|
||||||
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
|
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
|
||||||
Hatching a custom pet needs a provider that can ground on a reference image.
|
{t.surfaces.imageBackendDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onSetup} size="sm">
|
<Button onClick={onSetup} size="sm">
|
||||||
<Settings2 className="size-4" />
|
<Settings2 className="size-4" />
|
||||||
Set up image generation
|
{t.surfaces.setupImage}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||||
<span>Grab a key from</span>
|
<span>{t.surfaces.keyFrom}</span>
|
||||||
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
|
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
|
||||||
Nous Portal
|
Nous Portal
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
|
|||||||
|
|
||||||
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
|
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
|
||||||
<button
|
<button
|
||||||
aria-label="Remove reference"
|
aria-label={t.surfaces.removeReference}
|
||||||
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
|
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export function PetGenerateContent() {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ImageIcon className="size-3" />
|
<ImageIcon className="size-3" />
|
||||||
Add a reference
|
{t.surfaces.addReference}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
import { ErrorBoundary } from '@/components/error-boundary'
|
import { ErrorBoundary } from '@/components/error-boundary'
|
||||||
|
import { I18nProvider } from '@/i18n'
|
||||||
import { ThemeProvider } from '@/themes/context'
|
import { ThemeProvider } from '@/themes/context'
|
||||||
|
|
||||||
import { PetOverlayApp } from './pet-overlay-app'
|
import { PetOverlayApp } from './pet-overlay-app'
|
||||||
@@ -9,7 +10,7 @@ import { PetOverlayApp } from './pet-overlay-app'
|
|||||||
/**
|
/**
|
||||||
* Boot the pet-overlay window. Loaded by the same bundle as the main app but
|
* Boot the pet-overlay window. Loaded by the same bundle as the main app but
|
||||||
* via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent
|
* via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent
|
||||||
* surface (no app shell, no gateway, no I18n — the bubble strings are inline).
|
* surface. Its language and live state are mirrored from the main window.
|
||||||
*
|
*
|
||||||
* The index.html boot script paints an OPAQUE themed background to avoid a flash
|
* The index.html boot script paints an OPAQUE themed background to avoid a flash
|
||||||
* in normal windows; the overlay must be see-through, so we force every host
|
* in normal windows; the overlay must be see-through, so we force every host
|
||||||
@@ -30,7 +31,9 @@ export function mountPetOverlay(): void {
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary label="pet-overlay">
|
<ErrorBoundary label="pet-overlay">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<I18nProvider configClient={null}>
|
||||||
<PetOverlayApp />
|
<PetOverlayApp />
|
||||||
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts'
|
|||||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||||
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
|
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
|
||||||
|
import { normalizeLocale, useI18n } from '@/i18n'
|
||||||
import { Mail } from '@/lib/icons'
|
import { Mail } from '@/lib/icons'
|
||||||
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
||||||
import { overlayWindowSize } from '@/store/pet-overlay'
|
import { overlayWindowSize } from '@/store/pet-overlay'
|
||||||
@@ -61,6 +62,7 @@ interface DragState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PetOverlayApp() {
|
export function PetOverlayApp() {
|
||||||
|
const { t, setLocale } = useI18n()
|
||||||
const info = useStore($petInfo)
|
const info = useStore($petInfo)
|
||||||
const [composerOpen, setComposerOpen] = useState(false)
|
const [composerOpen, setComposerOpen] = useState(false)
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
@@ -90,6 +92,7 @@ export function PetOverlayApp() {
|
|||||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
|
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
|
||||||
|
void setLocale(normalizeLocale(payload.locale))
|
||||||
setPetInfo(payload.info)
|
setPetInfo(payload.info)
|
||||||
$petActivity.set(payload.activity ?? {})
|
$petActivity.set(payload.activity ?? {})
|
||||||
setBusy(Boolean(payload.busy))
|
setBusy(Boolean(payload.busy))
|
||||||
@@ -115,7 +118,7 @@ export function PetOverlayApp() {
|
|||||||
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
|
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
|
||||||
|
|
||||||
return off
|
return off
|
||||||
}, [])
|
}, [setLocale])
|
||||||
|
|
||||||
// Click-through: make only the *solid* sprite pixels (plus the bubble / mail
|
// Click-through: make only the *solid* sprite pixels (plus the bubble / mail
|
||||||
// button / open composer) interactive — clicks on the transparent rectangle
|
// button / open composer) interactive — clicks on the transparent rectangle
|
||||||
@@ -397,7 +400,7 @@ export function PetOverlayApp() {
|
|||||||
setComposerOpen(false)
|
setComposerOpen(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Message…"
|
placeholder={t.surfaces.pet.message}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--ui-bg-elevated)',
|
background: 'var(--ui-bg-elevated)',
|
||||||
@@ -447,7 +450,7 @@ export function PetOverlayApp() {
|
|||||||
stopPropagation keeps a click from starting a window drag. */}
|
stopPropagation keeps a click from starting a window drag. */}
|
||||||
{unread && (
|
{unread && (
|
||||||
<button
|
<button
|
||||||
aria-label="Open in Hermes"
|
aria-label={t.surfaces.pet.openApp}
|
||||||
onClick={openApp}
|
onClick={openApp}
|
||||||
onPointerDown={e => e.stopPropagation()}
|
onPointerDown={e => e.stopPropagation()}
|
||||||
onPointerUp={e => e.stopPropagation()}
|
onPointerUp={e => e.stopPropagation()}
|
||||||
@@ -468,7 +471,7 @@ export function PetOverlayApp() {
|
|||||||
top: 0,
|
top: 0,
|
||||||
width: 24
|
width: 24
|
||||||
}}
|
}}
|
||||||
title="Open in Hermes"
|
title={t.surfaces.pet.openApp}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Mail style={{ height: 13, width: 13 }} />
|
<Mail style={{ height: 13, width: 13 }} />
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n'
|
||||||
|
import type { QuickEntryStatePush } from '@/store/quick-entry'
|
||||||
|
|
||||||
|
import { QuickEntryApp } from './quick-entry-app'
|
||||||
|
|
||||||
|
const originalBridge = window.hermesDesktop
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: originalBridge })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('localized Quick Entry', () => {
|
||||||
|
it('adopts the main window language without losing the draft or changing the target ID', () => {
|
||||||
|
let push: (payload: QuickEntryStatePush) => void = () => {}
|
||||||
|
const submit = vi.fn()
|
||||||
|
const offState = vi.fn()
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
quickEntry: {
|
||||||
|
onShown: () => () => {},
|
||||||
|
onState: (callback: typeof push) => {
|
||||||
|
push = callback
|
||||||
|
|
||||||
|
return offState
|
||||||
|
},
|
||||||
|
submit,
|
||||||
|
dismiss: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const view = render(
|
||||||
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
|
<QuickEntryApp />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
const sessions = [{ id: 'stored-session-42', title: 'Customer’s custom title' }]
|
||||||
|
act(() => push({ connected: true, locale: 'en', sessions }))
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: 'Quick Entry' }), { target: { value: 'Dosyayı incele' } })
|
||||||
|
fireEvent.change(screen.getByRole('combobox', { name: 'Target session' }), {
|
||||||
|
target: { value: 'stored-session-42' }
|
||||||
|
})
|
||||||
|
act(() => push({ connected: true, locale: 'tr', sessions }))
|
||||||
|
const input = screen.getByRole('textbox', { name: 'Hızlı giriş' }) as HTMLInputElement
|
||||||
|
expect(input.value).toBe('Dosyayı incele')
|
||||||
|
expect(input.placeholder).toBe('AITURK’e sor…')
|
||||||
|
expect((screen.getByRole('combobox', { name: 'Hedef oturum' }) as HTMLSelectElement).value).toBe(
|
||||||
|
'stored-session-42'
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('option', { name: 'Customer’s custom title' })).toBeTruthy()
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
expect(submit).toHaveBeenCalledExactlyOnceWith({ target: 'stored-session-42', text: 'Dosyayı incele' })
|
||||||
|
view.unmount()
|
||||||
|
expect(offState).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useReducer, useRef } from 'react'
|
import { useEffect, useReducer, useRef } from 'react'
|
||||||
|
|
||||||
|
import { normalizeLocale, useI18n } from '@/i18n'
|
||||||
import {
|
import {
|
||||||
initialQuickComposerState,
|
initialQuickComposerState,
|
||||||
QUICK_TARGET_CURRENT,
|
QUICK_TARGET_CURRENT,
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
* the primary renderer's normal prompt-submit path.
|
* the primary renderer's normal prompt-submit path.
|
||||||
*/
|
*/
|
||||||
export function QuickEntryApp() {
|
export function QuickEntryApp() {
|
||||||
|
const { t, setLocale } = useI18n()
|
||||||
|
const copy = t.surfaces.quickEntry
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// The reducer returns { send, state }; this wrapper performs the side effect
|
// The reducer returns { send, state }; this wrapper performs the side effect
|
||||||
@@ -56,6 +59,7 @@ export function QuickEntryApp() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const offState = api?.onState(payload => {
|
const offState = api?.onState(payload => {
|
||||||
|
void setLocale(normalizeLocale(payload?.locale))
|
||||||
dispatch({
|
dispatch({
|
||||||
connected: payload?.connected === true,
|
connected: payload?.connected === true,
|
||||||
sessions: Array.isArray(payload?.sessions) ? payload.sessions : [],
|
sessions: Array.isArray(payload?.sessions) ? payload.sessions : [],
|
||||||
@@ -69,7 +73,7 @@ export function QuickEntryApp() {
|
|||||||
offShown?.()
|
offShown?.()
|
||||||
offState?.()
|
offState?.()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setLocale])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -110,7 +114,7 @@ export function QuickEntryApp() {
|
|||||||
›
|
›
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
aria-label="Quick Entry"
|
aria-label={copy.title}
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
@@ -131,7 +135,7 @@ export function QuickEntryApp() {
|
|||||||
dispatch({ type: 'dismiss' })
|
dispatch({ type: 'dismiss' })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={state.connected ? 'Ask Hermes…' : 'Not connected — open Hermes to reconnect'}
|
placeholder={state.connected ? copy.ask : copy.disconnected}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
style={{
|
style={{
|
||||||
@@ -158,10 +162,10 @@ export function QuickEntryApp() {
|
|||||||
userSelect: 'none'
|
userSelect: 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Send to
|
{copy.sendTo}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
aria-label="Target session"
|
aria-label={copy.target}
|
||||||
disabled={!state.connected}
|
disabled={!state.connected}
|
||||||
id="quick-entry-target"
|
id="quick-entry-target"
|
||||||
onChange={event => dispatch({ target: event.target.value, type: 'target' })}
|
onChange={event => dispatch({ target: event.target.value, type: 'target' })}
|
||||||
@@ -182,8 +186,8 @@ export function QuickEntryApp() {
|
|||||||
}}
|
}}
|
||||||
value={state.target}
|
value={state.target}
|
||||||
>
|
>
|
||||||
<option value={QUICK_TARGET_CURRENT}>Current chat</option>
|
<option value={QUICK_TARGET_CURRENT}>{copy.current}</option>
|
||||||
<option value={QUICK_TARGET_NEW}>New session</option>
|
<option value={QUICK_TARGET_NEW}>{copy.newSession}</option>
|
||||||
{state.sessions.map(session => (
|
{state.sessions.map(session => (
|
||||||
<option key={session.id} value={session.id}>
|
<option key={session.id} value={session.id}>
|
||||||
{session.title}
|
{session.title}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
import { ErrorBoundary } from '@/components/error-boundary'
|
import { ErrorBoundary } from '@/components/error-boundary'
|
||||||
|
import { I18nProvider } from '@/i18n'
|
||||||
import { ThemeProvider } from '@/themes/context'
|
import { ThemeProvider } from '@/themes/context'
|
||||||
|
|
||||||
import { QuickEntryApp } from './quick-entry-app'
|
import { QuickEntryApp } from './quick-entry-app'
|
||||||
@@ -30,7 +31,9 @@ export function mountQuickEntry(): void {
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary label="quick-entry">
|
<ErrorBoundary label="quick-entry">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<I18nProvider configClient={null}>
|
||||||
<QuickEntryApp />
|
<QuickEntryApp />
|
||||||
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ function MarketplaceThemeResults({
|
|||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||||
From the VS Code Marketplace
|
{t.settings.runtime.marketplaceThemes}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -519,7 +519,7 @@ export function AppearanceSettings() {
|
|||||||
<input
|
<input
|
||||||
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||||
onChange={event => setQuery(event.target.value)}
|
onChange={event => setQuery(event.target.value)}
|
||||||
placeholder="Search your themes or the VS Code Marketplace…"
|
placeholder={t.settings.runtime.themeSearch}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
@@ -531,7 +531,7 @@ export function AppearanceSettings() {
|
|||||||
{filteredThemes.length === 0 ? (
|
{filteredThemes.length === 0 ? (
|
||||||
needle ? (
|
needle ? (
|
||||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
No installed themes match "{query.trim()}".
|
{t.settings.runtime.noMatchingThemes(query.trim())}
|
||||||
</p>
|
</p>
|
||||||
) : null
|
) : null
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { RowValue } from './account-row-value'
|
|||||||
import type { BillingRefusal } from './api'
|
import type { BillingRefusal } from './api'
|
||||||
import { useBillingApi } from './api'
|
import { useBillingApi } from './api'
|
||||||
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
|
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
|
||||||
|
import { useBillingCopy } from './copy'
|
||||||
import { BillingRefusalInline } from './inline-feedback'
|
import { BillingRefusalInline } from './inline-feedback'
|
||||||
import type { BillingAutoReload, BillingStateResponse } from './types'
|
import type { BillingAutoReload, BillingStateResponse } from './types'
|
||||||
import type { BillingAccountRowView } from './use-billing-state'
|
import type { BillingAccountRowView } from './use-billing-state'
|
||||||
@@ -24,6 +25,8 @@ export function AutoReloadRow({
|
|||||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
|
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
|
||||||
row: BillingAccountRowView
|
row: BillingAccountRowView
|
||||||
}) {
|
}) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const api = useBillingApi()
|
const api = useBillingApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||||
@@ -45,7 +48,7 @@ export function AutoReloadRow({
|
|||||||
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
|
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
|
||||||
)
|
)
|
||||||
|
|
||||||
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
|
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds, copy)
|
||||||
const busy = saving
|
const busy = saving
|
||||||
const maxBound = bounds.max_usd ?? undefined
|
const maxBound = bounds.max_usd ?? undefined
|
||||||
const minBound = bounds.min_usd ?? undefined
|
const minBound = bounds.min_usd ?? undefined
|
||||||
@@ -95,7 +98,7 @@ export function AutoReloadRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||||
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
|
setMessage({ kind: 'success', text: copy.refillUpdated })
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +128,7 @@ export function AutoReloadRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||||
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
|
setMessage({ kind: 'success', text: copy.refillOff })
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +181,9 @@ export function AutoReloadRow({
|
|||||||
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
|
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
|
||||||
<div className="grid gap-2 @2xl:grid-cols-2">
|
<div className="grid gap-2 @2xl:grid-cols-2">
|
||||||
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
Threshold
|
{copy.threshold}
|
||||||
<Input
|
<Input
|
||||||
aria-label="Auto-refill threshold"
|
aria-label={copy.refillThreshold}
|
||||||
className="mt-1 py-[3px]"
|
className="mt-1 py-[3px]"
|
||||||
disabled={busy || !editing}
|
disabled={busy || !editing}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
@@ -195,9 +198,9 @@ export function AutoReloadRow({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
Reload to
|
{copy.reloadTo}
|
||||||
<Input
|
<Input
|
||||||
aria-label="Auto-refill reload-to amount"
|
aria-label={copy.refillAmount}
|
||||||
className="mt-1 py-[3px]"
|
className="mt-1 py-[3px]"
|
||||||
disabled={busy || !editing}
|
disabled={busy || !editing}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
@@ -218,9 +221,9 @@ export function AutoReloadRow({
|
|||||||
</div>
|
</div>
|
||||||
{confirmDisable ? (
|
{confirmDisable ? (
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
<span>Turn off auto-refill?</span>
|
<span>{copy.turnOffQuestion}</span>
|
||||||
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
|
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
|
||||||
Turn off
|
{copy.turnOff}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -229,7 +232,7 @@ export function AutoReloadRow({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Cancel
|
{copy.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -241,7 +244,7 @@ export function AutoReloadRow({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Disable
|
{copy.disable}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
|
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
|
||||||
@@ -261,15 +264,15 @@ export function AutoReloadRow({
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<>
|
<>
|
||||||
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
|
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
|
||||||
{busy ? 'Saving…' : 'Save'}
|
{busy ? copy.saving : copy.save}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
|
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
|
||||||
Cancel
|
{copy.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={openEdit} size="sm" type="button" variant="outline">
|
<Button onClick={openEdit} size="sm" type="button" variant="outline">
|
||||||
Manage
|
{copy.manage}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
|
||||||
import type { BillingStateResponse } from './types'
|
import type { BillingStateResponse } from './types'
|
||||||
import { EMPTY_BILLING_VALUE } from './use-billing-state'
|
import { EMPTY_BILLING_VALUE } from './use-billing-state'
|
||||||
|
|
||||||
@@ -49,22 +50,23 @@ export function initialAutoReloadAmount(...candidates: Array<null | string | und
|
|||||||
export function validateAutoReloadInputs(
|
export function validateAutoReloadInputs(
|
||||||
thresholdRaw: string,
|
thresholdRaw: string,
|
||||||
reloadToRaw: string,
|
reloadToRaw: string,
|
||||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
|
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
): { error?: string; values?: { reloadTo: string; threshold: string } } {
|
): { error?: string; values?: { reloadTo: string; threshold: string } } {
|
||||||
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
|
const threshold = validateBillingAmount(copy.threshold, thresholdRaw, bounds, copy)
|
||||||
|
|
||||||
if (threshold.error || threshold.amount == null) {
|
if (threshold.error || threshold.amount == null) {
|
||||||
return { error: threshold.error }
|
return { error: threshold.error }
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
|
const reloadTo = validateBillingAmount(copy.reloadLabel, reloadToRaw, bounds, copy)
|
||||||
|
|
||||||
if (reloadTo.error || reloadTo.amount == null) {
|
if (reloadTo.error || reloadTo.amount == null) {
|
||||||
return { error: reloadTo.error }
|
return { error: reloadTo.error }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reloadTo.amount <= threshold.amount) {
|
if (reloadTo.amount <= threshold.amount) {
|
||||||
return { error: 'Reload-to amount must be greater than the threshold.' }
|
return { error: copy.reloadGreater }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -78,30 +80,31 @@ export function validateAutoReloadInputs(
|
|||||||
export function validateBillingAmount(
|
export function validateBillingAmount(
|
||||||
label: string,
|
label: string,
|
||||||
raw: string,
|
raw: string,
|
||||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
|
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
): { amount?: number; error?: string } {
|
): { amount?: number; error?: string } {
|
||||||
const cleaned = raw.trim().replace(/^\$/, '').trim()
|
const cleaned = raw.trim().replace(/^\$/, '').trim()
|
||||||
|
|
||||||
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
|
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
|
||||||
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
|
return { error: copy.invalidAmount(label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = Number(cleaned)
|
const amount = Number(cleaned)
|
||||||
|
|
||||||
if (!(amount > 0)) {
|
if (!(amount > 0)) {
|
||||||
return { error: `${label}: amount must be greater than $0.` }
|
return { error: copy.positiveAmount(label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const min = parseAmount(bounds.min_usd)
|
const min = parseAmount(bounds.min_usd)
|
||||||
|
|
||||||
if (min != null && amount < min) {
|
if (min != null && amount < min) {
|
||||||
return { error: `${label}: minimum is ${formatMoney(min)}.` }
|
return { error: copy.minAmount(label, formatMoney(min)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const max = parseAmount(bounds.max_usd)
|
const max = parseAmount(bounds.max_usd)
|
||||||
|
|
||||||
if (max != null && amount > max) {
|
if (max != null && amount > max) {
|
||||||
return { error: `${label}: maximum is ${formatMoney(max)}.` }
|
return { error: copy.maxAmount(label, formatMoney(max)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { amount }
|
return { amount }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
import { EN_BILLING } from '@/i18n/en-billing'
|
||||||
|
import { TR_BILLING } from '@/i18n/tr-billing'
|
||||||
|
|
||||||
|
export type BillingCopy = typeof EN_BILLING
|
||||||
|
export const DEFAULT_BILLING_COPY: BillingCopy = EN_BILLING
|
||||||
|
|
||||||
|
export function useBillingCopy(): BillingCopy {
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
return locale === 'tr' ? TR_BILLING : EN_BILLING
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ExternalLink } from '@/lib/icons'
|
import { ExternalLink } from '@/lib/icons'
|
||||||
|
|
||||||
|
import { useBillingCopy } from './copy'
|
||||||
import { BillingRefusalInline } from './inline-feedback'
|
import { BillingRefusalInline } from './inline-feedback'
|
||||||
import { openExternal } from './open-external'
|
import { openExternal } from './open-external'
|
||||||
import { TierArt } from './tier-art'
|
import { TierArt } from './tier-art'
|
||||||
@@ -8,6 +9,8 @@ import type { BillingPlanCardView } from './use-billing-state'
|
|||||||
import { useResumeFlow } from './use-subscription-change'
|
import { useResumeFlow } from './use-subscription-change'
|
||||||
|
|
||||||
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
|
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const resumeFlow = useResumeFlow()
|
const resumeFlow = useResumeFlow()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,7 +25,8 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
|
|||||||
</span>
|
</span>
|
||||||
{plan.price && (
|
{plan.price && (
|
||||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
{plan.price}/mo
|
{plan.price}
|
||||||
|
{copy.monthSuffix}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +44,7 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
|
|||||||
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
|
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
|
||||||
{plan.pending && (
|
{plan.pending && (
|
||||||
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
|
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
|
||||||
{resumeFlow.busy ? 'Undoing…' : 'Undo'}
|
{resumeFlow.busy ? copy.undoing : copy.undo}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{plan.link && (
|
{plan.link && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BillingRefusal } from './api'
|
import type { BillingRefusal } from './api'
|
||||||
|
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
|
||||||
|
|
||||||
export interface BillingRefusalPresentation {
|
export interface BillingRefusalPresentation {
|
||||||
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
|
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
|
||||||
@@ -8,51 +9,51 @@ export interface BillingRefusalPresentation {
|
|||||||
|
|
||||||
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
|
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
|
||||||
|
|
||||||
const retryMessage = (refusal: BillingRefusal): string => {
|
const retryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
|
||||||
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
|
const mins = refusal.retryAfter ? copy.retryAfter(Math.max(1, Math.round(refusal.retryAfter / 60))) : ''
|
||||||
|
|
||||||
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
|
return copy.rateLimited(mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeRetryMessage = (refusal: BillingRefusal): string => {
|
const stripeRetryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
|
||||||
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
|
const mins = refusal.retryAfter ? copy.retryAfter(Math.max(1, Math.round(refusal.retryAfter / 60))) : ''
|
||||||
|
|
||||||
return `Stripe is having trouble — try again shortly${mins}`
|
return copy.stripeRetry(mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
|
export const resolveRefusal = (
|
||||||
|
refusal: BillingRefusal,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): BillingRefusalPresentation => {
|
||||||
switch (refusal.kind) {
|
switch (refusal.kind) {
|
||||||
case 'consent_required':
|
case 'consent_required':
|
||||||
return {
|
return {
|
||||||
action: portalAction(refusal.portalUrl),
|
action: portalAction(refusal.portalUrl),
|
||||||
message: 'Confirm this card for terminal charges in the portal',
|
message: copy.confirmCardMessage,
|
||||||
title: 'Card confirmation needed'
|
title: copy.confirmCardTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'insufficient_scope':
|
case 'insufficient_scope':
|
||||||
return {
|
return {
|
||||||
action: { type: 'step_up' },
|
action: { type: 'step_up' },
|
||||||
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
|
message: copy.spendingApprovalMessage,
|
||||||
title: 'Remote Spending needs approval'
|
title: copy.spendingApprovalTitle
|
||||||
}
|
}
|
||||||
case 'remote_spending_revoked': {
|
case 'remote_spending_revoked': {
|
||||||
const who =
|
const who = refusal.actor === 'admin' ? copy.adminRevoked : copy.userRevoked
|
||||||
refusal.actor === 'admin'
|
|
||||||
? 'An admin stopped remote spending for this terminal.'
|
|
||||||
: 'You stopped remote spending for this terminal.'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: portalAction(refusal.portalUrl),
|
action: portalAction(refusal.portalUrl),
|
||||||
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
|
message: copy.reauthorize(who),
|
||||||
title: 'Remote spending was stopped'
|
title: copy.spendingRevoked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'session_revoked':
|
case 'session_revoked':
|
||||||
return {
|
return {
|
||||||
action: portalAction(refusal.portalUrl),
|
action: portalAction(refusal.portalUrl),
|
||||||
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
|
message: copy.sessionRevokedMessage,
|
||||||
title: 'Session logged out'
|
title: copy.sessionRevokedTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'cli_billing_disabled':
|
case 'cli_billing_disabled':
|
||||||
@@ -60,50 +61,44 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
|
|||||||
case 'remote_spending_disabled':
|
case 'remote_spending_disabled':
|
||||||
return {
|
return {
|
||||||
action: portalAction(refusal.portalUrl),
|
action: portalAction(refusal.portalUrl),
|
||||||
message:
|
message: copy.spendingDisabledMessage,
|
||||||
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
|
title: copy.spendingDisabledTitle
|
||||||
title: 'Remote spending is off'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'role_required':
|
case 'role_required':
|
||||||
return {
|
return {
|
||||||
action: portalAction(refusal.portalUrl),
|
action: portalAction(refusal.portalUrl),
|
||||||
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
|
message: copy.adminRequiredMessage,
|
||||||
title: 'Admin role required'
|
title: copy.adminRequiredTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'idempotency_conflict':
|
case 'idempotency_conflict':
|
||||||
return {
|
return {
|
||||||
action: { type: 'none' },
|
action: { type: 'none' },
|
||||||
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
|
message: copy.conflictingChargeMessage,
|
||||||
title: 'Start a fresh top-up'
|
title: copy.conflictingChargeTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'no_payment_method':
|
case 'no_payment_method':
|
||||||
return {
|
return {
|
||||||
action: portalAction(refusal.portalUrl),
|
action: portalAction(refusal.portalUrl),
|
||||||
message:
|
message: copy.noSavedCardMessage,
|
||||||
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
|
title: copy.noSavedCard
|
||||||
"(one-time credit buys don't save a reusable card).",
|
|
||||||
title: 'No saved card'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'org_access_denied':
|
case 'org_access_denied':
|
||||||
return {
|
return {
|
||||||
action: { type: 'none' },
|
action: { type: 'none' },
|
||||||
message: "This token isn't bound to an org you can manage",
|
message: copy.orgDeniedMessage,
|
||||||
title: 'Org access denied'
|
title: copy.orgDeniedTitle
|
||||||
}
|
}
|
||||||
case 'monthly_cap_exceeded': {
|
case 'monthly_cap_exceeded': {
|
||||||
const remaining = refusal.payload?.remainingUsd
|
const remaining = refusal.payload?.remainingUsd
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: portalAction(refusal.portalUrl),
|
action: portalAction(refusal.portalUrl),
|
||||||
message:
|
message: remaining != null ? copy.monthlyRemaining(String(remaining)) : copy.monthlyReached,
|
||||||
remaining != null
|
title: copy.monthlyReachedTitle
|
||||||
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
|
|
||||||
: '🔴 Monthly spend cap reached.',
|
|
||||||
title: 'Monthly spend cap reached'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,52 +107,50 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
|
|||||||
case 'temporarily_unavailable':
|
case 'temporarily_unavailable':
|
||||||
return {
|
return {
|
||||||
action: { type: 'retry' },
|
action: { type: 'retry' },
|
||||||
message: retryMessage(refusal),
|
message: retryMessage(refusal, copy),
|
||||||
title: 'Too many charges right now'
|
title: copy.rateLimitedTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'stripe_unavailable':
|
case 'stripe_unavailable':
|
||||||
return {
|
return {
|
||||||
action: { type: 'retry' },
|
action: { type: 'retry' },
|
||||||
message: stripeRetryMessage(refusal),
|
message: stripeRetryMessage(refusal, copy),
|
||||||
title: 'Stripe is having trouble'
|
title: copy.stripeTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'upgrade_cap_exceeded':
|
case 'upgrade_cap_exceeded':
|
||||||
return {
|
return {
|
||||||
action: { type: 'none' },
|
action: { type: 'none' },
|
||||||
message: 'Daily plan-change limit reached — try again tomorrow',
|
message: copy.dailyLimitMessage,
|
||||||
title: 'Daily plan-change limit reached'
|
title: copy.dailyLimitTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'endpoint_unavailable':
|
case 'endpoint_unavailable':
|
||||||
return {
|
return {
|
||||||
action: { type: 'retry' },
|
action: { type: 'retry' },
|
||||||
message:
|
message: refusal.message || copy.endpointMessage,
|
||||||
refusal.message ||
|
title: copy.endpointTitle
|
||||||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
|
|
||||||
title: 'Billing endpoint unavailable'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'timeout':
|
case 'timeout':
|
||||||
return {
|
return {
|
||||||
action: { type: 'retry' },
|
action: { type: 'retry' },
|
||||||
message: refusal.message || 'Billing request timed out.',
|
message: refusal.message || copy.requestTimeoutMessage,
|
||||||
title: 'Billing request timed out'
|
title: copy.requestTimeoutTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'transport':
|
case 'transport':
|
||||||
return {
|
return {
|
||||||
action: { type: 'retry' },
|
action: { type: 'retry' },
|
||||||
message: refusal.message || 'Billing request failed before reaching the gateway.',
|
message: refusal.message || copy.connectionFailedMessage,
|
||||||
title: 'Billing connection failed'
|
title: copy.connectionFailedTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
action: { type: 'none' },
|
action: { type: 'none' },
|
||||||
message: refusal.message || 'Billing request failed.',
|
message: refusal.message || copy.requestFailedMessage,
|
||||||
title: 'Billing request failed'
|
title: copy.requestFailedTitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render as renderUi, screen, waitFor } from '@testing-library/react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { MemoryRouter } from 'react-router'
|
import { MemoryRouter } from 'react-router'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider, useI18n } from '@/i18n'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from '../test-locale'
|
||||||
|
|
||||||
import { formatMoney } from './billing-amounts'
|
import { formatMoney } from './billing-amounts'
|
||||||
import {
|
import {
|
||||||
billingDevFixtures,
|
billingDevFixtures,
|
||||||
@@ -692,3 +696,38 @@ describe('BillingSettings', () => {
|
|||||||
expect(screen.queryByText(/Updated/)).toBeNull()
|
expect(screen.queryByText(/Updated/)).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function BillingLocaleSwitch() {
|
||||||
|
const { setLocale } = useI18n()
|
||||||
|
|
||||||
|
return <button onClick={() => void setLocale('tr')}>Türkçe</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
it('switches loaded billing content to Turkish and preserves auto-refill request values', async () => {
|
||||||
|
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
renderUi(
|
||||||
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
|
<BillingLocaleSwitch />
|
||||||
|
<MemoryRouter initialEntries={['/settings?tab=billing']}>
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<BillingSettings />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
await screen.findByText('Buy credits now')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Türkçe' }))
|
||||||
|
expect(await screen.findByText('Şimdi kredi satın al')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('Buy credits now')).toBeNull()
|
||||||
|
expect(screen.getByText('Bakiye')).toBeTruthy()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Yönet' }))
|
||||||
|
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme eşiği' }), { target: { value: '15' } })
|
||||||
|
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme tutarı' }), { target: { value: '20' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: true, threshold_usd: '15', reload_to_usd: '20' })
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('Otomatik yükleme güncellendi.')).toBeTruthy()
|
||||||
|
expect(apiMocks.charge).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { RowValue } from './account-row-value'
|
|||||||
import { BillingApiProvider } from './api'
|
import { BillingApiProvider } from './api'
|
||||||
import { AutoReloadRow } from './auto-reload-row'
|
import { AutoReloadRow } from './auto-reload-row'
|
||||||
import { clampAmount, formatMoney } from './billing-amounts'
|
import { clampAmount, formatMoney } from './billing-amounts'
|
||||||
|
import { useBillingCopy } from './copy'
|
||||||
import { CurrentPlanCard } from './current-plan-card'
|
import { CurrentPlanCard } from './current-plan-card'
|
||||||
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
|
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
|
||||||
import { StepUpInlineAction } from './inline-feedback'
|
import { StepUpInlineAction } from './inline-feedback'
|
||||||
@@ -155,6 +156,8 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
|
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const presets = useMemo(
|
const presets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
billing.charge_presets.map((amount, index) => ({
|
billing.charge_presets.map((amount, index) => ({
|
||||||
@@ -192,7 +195,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
|||||||
value={amount}
|
value={amount}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
aria-label="Custom credit amount"
|
aria-label={copy.customCredit}
|
||||||
containerClassName="w-16"
|
containerClassName="w-16"
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
@@ -211,7 +214,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
|||||||
value={amount}
|
value={amount}
|
||||||
/>
|
/>
|
||||||
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
|
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
|
||||||
Buy
|
{copy.buy}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -250,12 +253,14 @@ function BuyCreditsOutcome({
|
|||||||
onRetry: () => void
|
onRetry: () => void
|
||||||
outcome: ReturnType<typeof useChargeFlow>['outcome']
|
outcome: ReturnType<typeof useChargeFlow>['outcome']
|
||||||
}) {
|
}) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const stepUp = useStepUpFlow()
|
const stepUp = useStepUpFlow()
|
||||||
|
|
||||||
if (busy) {
|
if (busy) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
Processing… checking settlement
|
{copy.processing}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -267,7 +272,7 @@ function BuyCreditsOutcome({
|
|||||||
if (outcome.kind === 'success') {
|
if (outcome.kind === 'success') {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
{formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing.
|
{copy.balanceAdded(formatMoney(outcome.amountUsd ?? amount))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -280,7 +285,7 @@ function BuyCreditsOutcome({
|
|||||||
</span>
|
</span>
|
||||||
{outcome.portalUrl && (
|
{outcome.portalUrl && (
|
||||||
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
|
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
|
||||||
Open portal
|
{copy.portal}
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -297,13 +302,13 @@ function BuyCreditsOutcome({
|
|||||||
</span>
|
</span>
|
||||||
{outcome.action?.type === 'retry' && (
|
{outcome.action?.type === 'retry' && (
|
||||||
<Button onClick={onRetry} size="sm" type="button" variant="outline">
|
<Button onClick={onRetry} size="sm" type="button" variant="outline">
|
||||||
Retry
|
{copy.retry}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
|
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
|
||||||
{portalUrl && (
|
{portalUrl && (
|
||||||
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
|
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
|
||||||
Open portal
|
{copy.portal}
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -312,8 +317,10 @@ function BuyCreditsOutcome({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
|
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const resolvedBar = bar ?? {
|
const resolvedBar = bar ?? {
|
||||||
label: `${fallbackLabel} usage`,
|
label: copy.usageLabel(fallbackLabel),
|
||||||
state: 'neutral',
|
state: 'neutral',
|
||||||
tone: 'topup',
|
tone: 'topup',
|
||||||
value: 0
|
value: 0
|
||||||
@@ -407,11 +414,13 @@ function BillingHeader({
|
|||||||
fixtureName?: BillingFixtureSelection
|
fixtureName?: BillingFixtureSelection
|
||||||
onFixtureChange?: (value: BillingFixtureSelection) => void
|
onFixtureChange?: (value: BillingFixtureSelection) => void
|
||||||
}) {
|
}) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
|
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span>Billing</span>
|
<span>{copy.billing}</span>
|
||||||
</div>
|
</div>
|
||||||
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
|
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
|
||||||
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
|
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
|
||||||
@@ -455,6 +464,8 @@ function BillingSettingsContent({
|
|||||||
fixtureName?: BillingFixtureSelection
|
fixtureName?: BillingFixtureSelection
|
||||||
onFixtureChange?: (value: BillingFixtureSelection) => void
|
onFixtureChange?: (value: BillingFixtureSelection) => void
|
||||||
}) {
|
}) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
|
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
|
||||||
|
|
||||||
// Fixture mode flows through the SAME query path — the simulated api (supplied by
|
// Fixture mode flows through the SAME query path — the simulated api (supplied by
|
||||||
@@ -476,7 +487,7 @@ function BillingSettingsContent({
|
|||||||
|
|
||||||
const billingResult = billingState.data
|
const billingResult = billingState.data
|
||||||
const subscriptionResult = subscriptionState.data
|
const subscriptionResult = subscriptionState.data
|
||||||
const view = deriveBillingView(billingResult, subscriptionResult)
|
const view = deriveBillingView(billingResult, subscriptionResult, copy)
|
||||||
const billing = billingResult?.ok ? billingResult.data : undefined
|
const billing = billingResult?.ok ? billingResult.data : undefined
|
||||||
|
|
||||||
const { paymentRow, refillRow, topupRow } = view
|
const { paymentRow, refillRow, topupRow } = view
|
||||||
@@ -515,7 +526,7 @@ function BillingSettingsContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view.plan && (
|
{view.plan && (
|
||||||
<SettingsSection icon={Package} title="Plan">
|
<SettingsSection icon={Package} title={copy.plan}>
|
||||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
@@ -524,7 +535,7 @@ function BillingSettingsContent({
|
|||||||
<SettingsSection
|
<SettingsSection
|
||||||
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
|
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
|
||||||
icon={CreditCard}
|
icon={CreditCard}
|
||||||
title="Payment & credits"
|
title={copy.paymentCredits}
|
||||||
>
|
>
|
||||||
{accountRows.map(row => (
|
{accountRows.map(row => (
|
||||||
<AccountRow billing={billing} key={row.id} row={row} />
|
<AccountRow billing={billing} key={row.id} row={row} />
|
||||||
@@ -533,7 +544,7 @@ function BillingSettingsContent({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view.usageRows.length > 0 && (
|
{view.usageRows.length > 0 && (
|
||||||
<SettingsSection icon={BarChart3} title="Usage">
|
<SettingsSection icon={BarChart3} title={copy.usage}>
|
||||||
<div className="@container">
|
<div className="@container">
|
||||||
{view.usageRows.map(row => (
|
{view.usageRows.map(row => (
|
||||||
<UsageRow key={row.id} row={row} />
|
<UsageRow key={row.id} row={row} />
|
||||||
@@ -544,7 +555,7 @@ function BillingSettingsContent({
|
|||||||
|
|
||||||
{
|
{
|
||||||
// no endpoint yet — NAS capability-board gap
|
// no endpoint yet — NAS capability-board gap
|
||||||
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title="Invoices" /> : null
|
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title={copy.invoices} /> : null
|
||||||
}
|
}
|
||||||
</SettingsContent>
|
</SettingsContent>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import { openExternalLink } from '@/lib/external-link'
|
|||||||
import { ExternalLink } from '@/lib/icons'
|
import { ExternalLink } from '@/lib/icons'
|
||||||
|
|
||||||
import type { BillingRefusal } from './api'
|
import type { BillingRefusal } from './api'
|
||||||
|
import { useBillingCopy } from './copy'
|
||||||
import { resolveRefusal } from './errors'
|
import { resolveRefusal } from './errors'
|
||||||
import { useStepUpFlow } from './use-step-up'
|
import { useStepUpFlow } from './use-step-up'
|
||||||
|
|
||||||
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
|
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
if (flow.verification) {
|
if (flow.verification) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
|
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
|
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
|
||||||
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
|
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
|
||||||
Open verification page
|
{copy.verificationPage}
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
@@ -26,31 +29,33 @@ export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUp
|
|||||||
{flow.message.title}: {flow.message.text}
|
{flow.message.title}: {flow.message.text}
|
||||||
</span>
|
</span>
|
||||||
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
|
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
|
||||||
Dismiss
|
{copy.dismiss}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flow.phase === 'waiting') {
|
if (flow.phase === 'waiting') {
|
||||||
return <span>Waiting for verification link…</span>
|
return <span>{copy.waitingVerification}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
|
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
|
||||||
Verify to continue
|
{copy.verifyContinue}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
|
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const stepUp = useStepUpFlow()
|
const stepUp = useStepUpFlow()
|
||||||
|
|
||||||
if (!refusal) {
|
if (!refusal) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = resolveRefusal(refusal)
|
const resolved = resolveRefusal(refusal, copy)
|
||||||
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
|
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +66,7 @@ export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | nu
|
|||||||
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
|
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
|
||||||
{portalUrl && (
|
{portalUrl && (
|
||||||
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
|
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
|
||||||
Open portal
|
{copy.portal}
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { TR_BILLING } from '@/i18n/tr-billing'
|
||||||
|
|
||||||
|
import { validateAutoReloadInputs } from './billing-amounts'
|
||||||
|
import { resolveRefusal } from './errors'
|
||||||
|
import { okBilling, okSubscription, postTrainBillingState, postTrainSubscriptionState } from './fixtures.test-util'
|
||||||
|
import { deriveBillingView } from './use-billing-state'
|
||||||
|
|
||||||
|
describe('Turkish billing contracts', () => {
|
||||||
|
it('keeps routes, amount bounds and action policy unchanged across languages', () => {
|
||||||
|
for (const kind of [
|
||||||
|
'consent_required',
|
||||||
|
'insufficient_scope',
|
||||||
|
'remote_spending_revoked',
|
||||||
|
'session_revoked',
|
||||||
|
'cli_billing_disabled',
|
||||||
|
'remote_spending_disabled',
|
||||||
|
'role_required',
|
||||||
|
'idempotency_conflict',
|
||||||
|
'no_payment_method',
|
||||||
|
'org_access_denied',
|
||||||
|
'monthly_cap_exceeded',
|
||||||
|
'rate_limited',
|
||||||
|
'temporarily_unavailable',
|
||||||
|
'stripe_unavailable',
|
||||||
|
'upgrade_cap_exceeded',
|
||||||
|
'endpoint_unavailable',
|
||||||
|
'timeout',
|
||||||
|
'transport',
|
||||||
|
'unknown'
|
||||||
|
]) {
|
||||||
|
const refusal = { kind, message: '', portalUrl: 'https://portal.nousresearch.com/billing', retryAfter: 120 }
|
||||||
|
const english = resolveRefusal(refusal)
|
||||||
|
const turkish = resolveRefusal(refusal, TR_BILLING)
|
||||||
|
expect(turkish.action).toEqual(english.action)
|
||||||
|
expect(turkish.title).not.toBe(english.title)
|
||||||
|
expect(turkish.message).not.toBe(english.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = { min_usd: '10', max_usd: '100' }
|
||||||
|
expect(validateAutoReloadInputs('15', '20', bounds, TR_BILLING)).toEqual(
|
||||||
|
validateAutoReloadInputs('15', '20', bounds)
|
||||||
|
)
|
||||||
|
expect(validateAutoReloadInputs('20', '15', bounds, TR_BILLING).error).toBe(TR_BILLING.reloadGreater)
|
||||||
|
expect(resolveRefusal({ kind: 'transport', message: 'upstream diagnostic' }, TR_BILLING).message).toBe(
|
||||||
|
'upstream diagnostic'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates the derived overview without altering prices or plan identities', () => {
|
||||||
|
const args = [okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState)] as const
|
||||||
|
const english = deriveBillingView(...args)
|
||||||
|
const turkish = deriveBillingView(...args, TR_BILLING)
|
||||||
|
expect(turkish.summary[0].label).toBe('Bakiye')
|
||||||
|
expect(turkish.summary[0].value).toBe(english.summary[0].value)
|
||||||
|
expect(turkish.plan?.tierName).toBe(english.plan?.tierName)
|
||||||
|
expect(turkish.plan?.price).toBe(english.plan?.price)
|
||||||
|
expect(turkish.paymentRow?.action?.url).toBe(english.paymentRow?.action?.url)
|
||||||
|
expect(turkish.tiers.map(tier => [tier.tierId, tier.state, tier.priceDisplay])).toEqual(
|
||||||
|
english.tiers.map(tier => [tier.tierId, tier.state, tier.priceDisplay])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
import { Pill } from '../primitives'
|
import { Pill } from '../primitives'
|
||||||
|
|
||||||
|
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
|
||||||
import { BillingRefusalInline } from './inline-feedback'
|
import { BillingRefusalInline } from './inline-feedback'
|
||||||
import { TierArt } from './tier-art'
|
import { TierArt } from './tier-art'
|
||||||
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
|
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
|
||||||
@@ -16,9 +17,13 @@ type DowngradeFlow = ReturnType<typeof useDowngradeFlow>
|
|||||||
|
|
||||||
// The human sentence for the panel body, derived purely from the phase. `null` while
|
// The human sentence for the panel body, derived purely from the phase. `null` while
|
||||||
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
|
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
|
||||||
function previewMessage(phase: DowngradePhase, fallbackTierName: string): null | string {
|
function previewMessage(
|
||||||
|
phase: DowngradePhase,
|
||||||
|
fallbackTierName: string,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): null | string {
|
||||||
if (phase.kind === 'previewing') {
|
if (phase.kind === 'previewing') {
|
||||||
return 'Checking this change…'
|
return copy.checkingChange
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase.kind === 'previewFailed') {
|
if (phase.kind === 'previewFailed') {
|
||||||
@@ -27,28 +32,27 @@ function previewMessage(phase: DowngradePhase, fallbackTierName: string): null |
|
|||||||
|
|
||||||
const { preview } = phase
|
const { preview } = phase
|
||||||
const targetName = preview.target_tier_name ?? fallbackTierName
|
const targetName = preview.target_tier_name ?? fallbackTierName
|
||||||
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta)
|
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta, copy)
|
||||||
|
|
||||||
switch (preview.effect) {
|
switch (preview.effect) {
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return preview.reason ?? 'That change cannot be made here.'
|
return preview.reason ?? copy.cannotChange
|
||||||
|
|
||||||
case 'no_op':
|
case 'no_op':
|
||||||
return `You are already on ${targetName} — nothing to change.`
|
return copy.alreadyPlan(targetName)
|
||||||
|
|
||||||
case 'scheduled':
|
case 'scheduled':
|
||||||
return (
|
return copy.scheduledChange(targetName, formatBillingDate(preview.effective_at, copy), creditsDelta)
|
||||||
`Change to ${targetName} — takes effect ${formatBillingDate(preview.effective_at)}. No charge now; ` +
|
|
||||||
`you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
|
|
||||||
)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'This change cannot be scheduled here.'
|
return copy.cannotSchedule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
|
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
|
||||||
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
|
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const active = flow.active
|
const active = flow.active
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
const open = active?.target.tierId === tier.tierId
|
const open = active?.target.tierId === tier.tierId
|
||||||
@@ -69,7 +73,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
|||||||
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||||
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
|
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
|
||||||
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
|
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
|
||||||
const message = previewMessage(phase, tier.name)
|
const message = previewMessage(phase, tier.name, copy)
|
||||||
|
|
||||||
const canConfirm =
|
const canConfirm =
|
||||||
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
|
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
|
||||||
@@ -91,19 +95,19 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
|||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
{phase.kind === 'previewFailed' ? (
|
{phase.kind === 'previewFailed' ? (
|
||||||
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
|
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
|
||||||
Try again
|
{copy.tryAgain}
|
||||||
</Button>
|
</Button>
|
||||||
) : canConfirm ? (
|
) : canConfirm ? (
|
||||||
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
|
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
|
||||||
{phase.kind === 'scheduling'
|
{phase.kind === 'scheduling'
|
||||||
? 'Scheduling…'
|
? copy.scheduling
|
||||||
: phase.kind === 'scheduleFailed'
|
: phase.kind === 'scheduleFailed'
|
||||||
? 'Try again'
|
? copy.tryAgain
|
||||||
: 'Confirm downgrade'}
|
: copy.confirmDowngrade}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
|
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
|
||||||
Cancel
|
{copy.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +115,8 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
|
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
const isCurrent = tier.state === 'current'
|
const isCurrent = tier.state === 'current'
|
||||||
const confirming = flow.active?.target.tierId === tier.tierId
|
const confirming = flow.active?.target.tierId === tier.tierId
|
||||||
const cardRef = useRef<HTMLDivElement>(null)
|
const cardRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -143,7 +149,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
|||||||
{tier.name}
|
{tier.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
{tier.priceDisplay}/mo
|
{tier.priceDisplay}
|
||||||
|
{copy.monthSuffix}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,9 +162,9 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto min-w-0 pt-1">
|
<div className="mt-auto min-w-0 pt-1">
|
||||||
{isCurrent && <Pill tone="primary">Current plan</Pill>}
|
{isCurrent && <Pill tone="primary">{copy.currentPlan}</Pill>}
|
||||||
|
|
||||||
{tier.state === 'scheduled' && <Pill>Scheduled</Pill>}
|
{tier.state === 'scheduled' && <Pill>{copy.scheduled}</Pill>}
|
||||||
|
|
||||||
{tier.state === 'upgrade' && (
|
{tier.state === 'upgrade' && (
|
||||||
<Button
|
<Button
|
||||||
@@ -183,7 +190,7 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Downgrade
|
{copy.downgrade}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -192,6 +199,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
|
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
|
||||||
|
const copy = useBillingCopy()
|
||||||
|
|
||||||
// A scheduled downgrade lands the user back on the overview, where the plan card
|
// A scheduled downgrade lands the user back on the overview, where the plan card
|
||||||
// now shows the pending state with its undo.
|
// now shows the pending state with its undo.
|
||||||
const flow = useDowngradeFlow({ onScheduled: onBack })
|
const flow = useDowngradeFlow({ onScheduled: onBack })
|
||||||
@@ -200,7 +209,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
|||||||
<div className="@container">
|
<div className="@container">
|
||||||
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||||
<Button
|
<Button
|
||||||
aria-label="Back to billing"
|
aria-label={copy.backBilling}
|
||||||
className="size-7 p-0 text-(--ui-text-tertiary)"
|
className="size-7 p-0 text-(--ui-text-tertiary)"
|
||||||
disabled={flow.mutating}
|
disabled={flow.mutating}
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
@@ -210,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
|||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span>Plans</span>
|
<span>{copy.plans}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tiers.length > 0 ? (
|
{tiers.length > 0 ? (
|
||||||
@@ -221,7 +230,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
No plans are available to change to right now.
|
{copy.noPlans}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { fmtDate } from '@/lib/time'
|
|
||||||
|
|
||||||
import type { BillingRefusal, BillingResult } from './api'
|
import type { BillingRefusal, BillingResult } from './api'
|
||||||
import { useBillingApi } from './api'
|
import { useBillingApi } from './api'
|
||||||
|
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
|
||||||
import { resolveRefusal } from './errors'
|
import { resolveRefusal } from './errors'
|
||||||
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
|
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ const BILLING_QUERY_OPTIONS = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface BillingSummaryItemView {
|
export interface BillingSummaryItemView {
|
||||||
label: 'Auto-refill' | 'Balance' | 'Plan'
|
label: string
|
||||||
tone?: 'muted' | 'primary'
|
tone?: 'muted' | 'primary'
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
@@ -172,12 +171,13 @@ export function useSubscriptionState(enabled = true) {
|
|||||||
|
|
||||||
export function deriveBillingView(
|
export function deriveBillingView(
|
||||||
stateResult?: BillingResult<BillingStateResponse>,
|
stateResult?: BillingResult<BillingStateResponse>,
|
||||||
subscriptionResult?: BillingResult<SubscriptionStateResponse>
|
subscriptionResult?: BillingResult<SubscriptionStateResponse>,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
): BillingView {
|
): BillingView {
|
||||||
if (!stateResult) {
|
if (!stateResult) {
|
||||||
return {
|
return {
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
summary: emptySummary(),
|
summary: emptySummary(copy),
|
||||||
tiers: [],
|
tiers: [],
|
||||||
usageRows: []
|
usageRows: []
|
||||||
}
|
}
|
||||||
@@ -185,9 +185,9 @@ export function deriveBillingView(
|
|||||||
|
|
||||||
if (!stateResult.ok) {
|
if (!stateResult.ok) {
|
||||||
return {
|
return {
|
||||||
notice: refusalNotice(stateResult.refusal),
|
notice: refusalNotice(stateResult.refusal, copy),
|
||||||
status: 'refusal',
|
status: 'refusal',
|
||||||
summary: emptySummary(),
|
summary: emptySummary(copy),
|
||||||
tiers: [],
|
tiers: [],
|
||||||
usageRows: []
|
usageRows: []
|
||||||
}
|
}
|
||||||
@@ -199,12 +199,12 @@ export function deriveBillingView(
|
|||||||
if (!billing.logged_in || subscription?.logged_in === false) {
|
if (!billing.logged_in || subscription?.logged_in === false) {
|
||||||
return {
|
return {
|
||||||
notice: {
|
notice: {
|
||||||
action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
|
action: { label: copy.portalArrow, url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
|
||||||
message: 'Run /portal in the TUI or open the Nous portal to connect your account.',
|
message: copy.connectMessage,
|
||||||
title: 'Connect your Nous account'
|
title: copy.connectTitle
|
||||||
},
|
},
|
||||||
status: 'logged_out',
|
status: 'logged_out',
|
||||||
summary: emptySummary(),
|
summary: emptySummary(copy),
|
||||||
tiers: [],
|
tiers: [],
|
||||||
usageRows: []
|
usageRows: []
|
||||||
}
|
}
|
||||||
@@ -216,27 +216,27 @@ export function deriveBillingView(
|
|||||||
const capable = plansCapable(subscription, subscriptionResult)
|
const capable = plansCapable(subscription, subscriptionResult)
|
||||||
// Computed once and threaded to both the card (caption + undo) and the grid
|
// Computed once and threaded to both the card (caption + undo) and the grid
|
||||||
// (Scheduled marker), so the two never disagree about what's pending.
|
// (Scheduled marker), so the two never disagree about what's pending.
|
||||||
const pending = pendingTransition(subscription?.current)
|
const pending = pendingTransition(subscription?.current, copy)
|
||||||
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
|
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending, copy)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notice: noCardNotice(billing),
|
notice: noCardNotice(billing, copy),
|
||||||
paymentRow: paymentMethodRow(billing),
|
paymentRow: paymentMethodRow(billing, copy),
|
||||||
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
|
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending, copy),
|
||||||
refillRow: autoReloadRow(billing),
|
refillRow: autoReloadRow(billing, copy),
|
||||||
status: 'normal',
|
status: 'normal',
|
||||||
summary: [
|
summary: [
|
||||||
{ label: 'Balance', value: displayBalance(billing) },
|
{ label: copy.balance, value: displayBalance(billing) },
|
||||||
{ label: 'Plan', value: displayPlan(subscription, billing.usage) },
|
{ label: copy.plan, value: displayPlan(subscription, billing.usage, copy) },
|
||||||
{
|
{
|
||||||
label: 'Auto-refill',
|
label: copy.autoRefill,
|
||||||
tone: billing.auto_reload?.enabled ? 'primary' : billing.auto_reload ? 'muted' : undefined,
|
tone: billing.auto_reload?.enabled ? 'primary' : billing.auto_reload ? 'muted' : undefined,
|
||||||
value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE
|
value: billing.auto_reload ? (billing.auto_reload.enabled ? copy.enabled : copy.off) : EMPTY_BILLING_VALUE
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
tiers,
|
tiers,
|
||||||
topupRow: buyCreditsRow(billing),
|
topupRow: buyCreditsRow(billing, copy),
|
||||||
usageRows: deriveUsageRows(billing, subscription)
|
usageRows: deriveUsageRows(billing, subscription, copy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ export function buildManageSubscriptionUrl(
|
|||||||
return FALLBACK_PORTAL_BILLING_URL
|
return FALLBACK_PORTAL_BILLING_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBillingDate(value?: null | string): string {
|
export function formatBillingDate(value?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return EMPTY_BILLING_VALUE
|
return EMPTY_BILLING_VALUE
|
||||||
}
|
}
|
||||||
@@ -286,23 +286,23 @@ export function formatBillingDate(value?: null | string): string {
|
|||||||
return EMPTY_BILLING_VALUE
|
return EMPTY_BILLING_VALUE
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmtDate.format(date)
|
return copy.formatDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptySummary(): BillingSummaryItemView[] {
|
function emptySummary(copy: BillingCopy = DEFAULT_BILLING_COPY): BillingSummaryItemView[] {
|
||||||
return [
|
return [
|
||||||
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
|
{ label: copy.balance, value: EMPTY_BILLING_VALUE },
|
||||||
{ label: 'Plan', value: EMPTY_BILLING_VALUE },
|
{ label: copy.plan, value: EMPTY_BILLING_VALUE },
|
||||||
{ label: 'Auto-refill', value: EMPTY_BILLING_VALUE }
|
{ label: copy.autoRefill, value: EMPTY_BILLING_VALUE }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
|
function refusalNotice(refusal: BillingRefusal, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingNoticeView {
|
||||||
const resolved = resolveRefusal(refusal)
|
const resolved = resolveRefusal(refusal, copy)
|
||||||
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
|
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
|
action: portalUrl ? { label: copy.portalArrow, url: portalUrl } : undefined,
|
||||||
message: resolved.message,
|
message: resolved.message,
|
||||||
title: resolved.title,
|
title: resolved.title,
|
||||||
tone: 'warn'
|
tone: 'warn'
|
||||||
@@ -312,15 +312,18 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
|
|||||||
// A logged-in account with no card can't buy credits or manage auto-refill, and
|
// A logged-in account with no card can't buy credits or manage auto-refill, and
|
||||||
// every one of those controls disables silently — so lead the page with a single
|
// every one of those controls disables silently — so lead the page with a single
|
||||||
// warn banner that names the blocker and links straight to the fix.
|
// warn banner that names the blocker and links straight to the fix.
|
||||||
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
|
function noCardNotice(
|
||||||
|
billing: BillingStateResponse,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): BillingNoticeView | undefined {
|
||||||
if (billing.card) {
|
if (billing.card) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
|
action: { label: copy.addCard, url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
|
||||||
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
|
message: copy.noCardMessage,
|
||||||
title: 'No payment method on file',
|
title: copy.noCardTitle,
|
||||||
tone: 'warn'
|
tone: 'warn'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,10 +351,13 @@ function plansCapable(
|
|||||||
|
|
||||||
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
|
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
|
||||||
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
|
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
|
||||||
function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined {
|
function creditsPerMonthDisplay(
|
||||||
|
monthlyCredits: null | string,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): string | undefined {
|
||||||
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
|
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
|
||||||
|
|
||||||
return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined
|
return Number.isFinite(credits) && credits > 0 ? copy.creditsMonthly(credits.toLocaleString('en-US')) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -360,14 +366,17 @@ function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefin
|
|||||||
* ("−$88/mo"), never the raw number. Zero / absent → null so the caller hides
|
* ("−$88/mo"), never the raw number. Zero / absent → null so the caller hides
|
||||||
* the line entirely.
|
* the line entirely.
|
||||||
*/
|
*/
|
||||||
export function formatMonthlyCreditsDelta(delta?: null | string): null | string {
|
export function formatMonthlyCreditsDelta(
|
||||||
|
delta?: null | string,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): null | string {
|
||||||
const amount = parseAmount(delta)
|
const amount = parseAmount(delta)
|
||||||
|
|
||||||
if (amount == null || amount === 0) {
|
if (amount == null || amount === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${amount < 0 ? '−' : '+'}${formatMoney(Math.abs(amount))}/mo`
|
return `${amount < 0 ? '−' : '+'}${formatMoney(Math.abs(amount))}${copy.monthSuffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,25 +393,26 @@ function derivePlanCard(
|
|||||||
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
|
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
|
||||||
tiers: BillingPlanTierView[],
|
tiers: BillingPlanTierView[],
|
||||||
capable: boolean,
|
capable: boolean,
|
||||||
pending: PendingPlanTransition | undefined
|
pending: PendingPlanTransition | undefined,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
): BillingPlanCardView {
|
): BillingPlanCardView {
|
||||||
const current = subscription?.current
|
const current = subscription?.current
|
||||||
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
|
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? copy.free
|
||||||
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
|
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
|
||||||
// still shows its price.
|
// still shows its price.
|
||||||
const price = findCurrentTier(subscription)?.dollars_per_month_display
|
const price = findCurrentTier(subscription)?.dollars_per_month_display
|
||||||
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
|
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at, copy)
|
||||||
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
|
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
|
||||||
|
|
||||||
const caption = unavailable
|
const caption = unavailable
|
||||||
? 'Subscription details are unavailable; opening the portal is still available.'
|
? copy.subscriptionUnavailable
|
||||||
: pending
|
: pending
|
||||||
? pending.kind === 'downgrade'
|
? pending.kind === 'downgrade'
|
||||||
? `Changes to ${pending.tierName} on ${pending.when}.`
|
? copy.changesPlan(pending.tierName, pending.when)
|
||||||
: `Cancels on ${pending.when}.`
|
: copy.cancelsOn(pending.when)
|
||||||
: current
|
: current
|
||||||
? `Renews ${renewal}`
|
? copy.renewsOn(renewal)
|
||||||
: 'No active subscription — paid models draw down top-up credits.'
|
: copy.noSubscription
|
||||||
|
|
||||||
// Actionable = a paid tier above (upgrade) or an in-app downgrade below the current
|
// Actionable = a paid tier above (upgrade) or an in-app downgrade below the current
|
||||||
// one. Ticket 11 counts downgrades (they act in-app, so they carry no `action`); a
|
// one. Ticket 11 counts downgrades (they act in-app, so they carry no `action`); a
|
||||||
@@ -410,14 +420,14 @@ function derivePlanCard(
|
|||||||
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
|
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
|
||||||
|
|
||||||
if (capable && hasActionableTier) {
|
if (capable && hasActionableTier) {
|
||||||
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, pending, price, tierName }
|
return { action: { label: current ? copy.changePlan : copy.viewPlans }, caption, pending, price, tierName }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
caption,
|
caption,
|
||||||
// No in-app action → always hand off to the portal so the user isn't stranded.
|
// No in-app action → always hand off to the portal so the user isn't stranded.
|
||||||
link: {
|
link: {
|
||||||
label: 'Adjust plan ↗',
|
label: copy.adjustPlan,
|
||||||
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
|
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
|
||||||
},
|
},
|
||||||
pending,
|
pending,
|
||||||
@@ -432,20 +442,21 @@ function derivePlanCard(
|
|||||||
// Precedence: a downgrade WINS if both are somehow set — it names a concrete target
|
// Precedence: a downgrade WINS if both are somehow set — it names a concrete target
|
||||||
// tier, the stronger, more specific signal, and is what the grid marks.
|
// tier, the stronger, more specific signal, and is what the grid marks.
|
||||||
function pendingTransition(
|
function pendingTransition(
|
||||||
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>
|
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
): PendingPlanTransition | undefined {
|
): PendingPlanTransition | undefined {
|
||||||
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
|
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
|
||||||
return {
|
return {
|
||||||
kind: 'downgrade',
|
kind: 'downgrade',
|
||||||
tierName: current.pending_downgrade_tier_name,
|
tierName: current.pending_downgrade_tier_name,
|
||||||
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at)
|
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at, copy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current?.cancel_at_period_end && current.cancellation_effective_at) {
|
if (current?.cancel_at_period_end && current.cancellation_effective_at) {
|
||||||
return {
|
return {
|
||||||
kind: 'cancellation',
|
kind: 'cancellation',
|
||||||
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at)
|
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at, copy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +482,8 @@ function derivePlanTiers(
|
|||||||
subscription: null | SubscriptionStateResponse,
|
subscription: null | SubscriptionStateResponse,
|
||||||
fallbackPortalUrl: null | string,
|
fallbackPortalUrl: null | string,
|
||||||
capable: boolean,
|
capable: boolean,
|
||||||
pending: PendingPlanTransition | undefined
|
pending: PendingPlanTransition | undefined,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
): BillingPlanTierView[] {
|
): BillingPlanTierView[] {
|
||||||
if (!capable || !subscription) {
|
if (!capable || !subscription) {
|
||||||
return []
|
return []
|
||||||
@@ -503,7 +515,7 @@ function derivePlanTiers(
|
|||||||
|
|
||||||
return gridTiers.map((tier): BillingPlanTierView => {
|
return gridTiers.map((tier): BillingPlanTierView => {
|
||||||
const base: BillingPlanTierBase = {
|
const base: BillingPlanTierBase = {
|
||||||
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
|
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits, copy),
|
||||||
name: tier.name,
|
name: tier.name,
|
||||||
priceDisplay: tier.dollars_per_month_display,
|
priceDisplay: tier.dollars_per_month_display,
|
||||||
tierId: tier.tier_id
|
tierId: tier.tier_id
|
||||||
@@ -529,13 +541,16 @@ function derivePlanTiers(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
|
action: { label: copy.choose, url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
|
||||||
state: 'upgrade'
|
state: 'upgrade'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView {
|
function paymentMethodRow(
|
||||||
|
billing: BillingStateResponse,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): BillingAccountRowView {
|
||||||
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
|
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
|
||||||
const card = billing.card
|
const card = billing.card
|
||||||
|
|
||||||
@@ -544,81 +559,80 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
|
|||||||
// it. The reason (buys/auto-refill are blocked) already leads the page as a
|
// it. The reason (buys/auto-refill are blocked) already leads the page as a
|
||||||
// notice, so the row stays a bare call-to-action with no redundant status text.
|
// notice, so the row stays a bare call-to-action with no redundant status text.
|
||||||
return {
|
return {
|
||||||
action: { label: 'Add payment method', url: portalUrl },
|
action: { label: copy.addPayment, url: portalUrl },
|
||||||
description: '',
|
description: '',
|
||||||
id: 'payment_method',
|
id: 'payment_method',
|
||||||
title: 'Payment method'
|
title: copy.paymentMethod
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: { label: 'Update', url: portalUrl },
|
action: { label: copy.update, url: portalUrl },
|
||||||
description: 'Manage the card used for top-ups and subscription renewals.',
|
description: copy.manageCard,
|
||||||
id: 'payment_method',
|
id: 'payment_method',
|
||||||
title: 'Payment method',
|
title: copy.paymentMethod,
|
||||||
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via)}`
|
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via, copy)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
|
function buyCreditsRow(billing: BillingStateResponse, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingAccountRowView {
|
||||||
if (!billing.card) {
|
if (!billing.card) {
|
||||||
// The no-card blocker is already spelled out by the page-level warn banner
|
// The no-card blocker is already spelled out by the page-level warn banner
|
||||||
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
|
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
|
||||||
// so keep the plain "what buying does" line and let the controls sit disabled.
|
// so keep the plain "what buying does" line and let the controls sit disabled.
|
||||||
return {
|
return {
|
||||||
action: { disabled: true, label: 'Buy' },
|
action: { disabled: true, label: copy.buy },
|
||||||
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
|
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
|
||||||
description: 'A single charge on your card, added to your balance today.',
|
description: copy.buyDescription,
|
||||||
id: 'buy_credits',
|
id: 'buy_credits',
|
||||||
title: 'Buy credits now'
|
title: copy.buyCredits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledReason = buyCreditsDisabledReason(billing)
|
const disabledReason = buyCreditsDisabledReason(billing, copy)
|
||||||
|
|
||||||
if (disabledReason) {
|
if (disabledReason) {
|
||||||
return {
|
return {
|
||||||
description: disabledReason,
|
description: disabledReason,
|
||||||
id: 'buy_credits',
|
id: 'buy_credits',
|
||||||
title: 'Buy credits now'
|
title: copy.buyCredits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: { disabled: true, label: 'Buy' },
|
action: { disabled: true, label: copy.buy },
|
||||||
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
|
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
|
||||||
description: 'A single charge on your card, added to your balance today.',
|
description: copy.buyDescription,
|
||||||
id: 'buy_credits',
|
id: 'buy_credits',
|
||||||
title: 'Buy credits now'
|
title: copy.buyCredits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generic first sentence shared by the off / absent / divergent states,
|
// The generic first sentence shared by the off / absent / divergent states,
|
||||||
// where the concrete amounts aren't the headline. The configured state overrides
|
// where the concrete amounts aren't the headline. The configured state overrides
|
||||||
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
|
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
|
||||||
const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.'
|
|
||||||
|
|
||||||
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
|
function autoReloadRow(billing: BillingStateResponse, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingAccountRowView {
|
||||||
const autoReload = billing.auto_reload
|
const autoReload = billing.auto_reload
|
||||||
|
|
||||||
if (!autoReload) {
|
if (!autoReload) {
|
||||||
return {
|
return {
|
||||||
action: { disabled: true, label: 'Manage' },
|
action: { disabled: true, label: copy.manage },
|
||||||
caption: 'Manage auto-refill from the portal.',
|
caption: copy.manageRefill,
|
||||||
description: AUTO_REFILL_GENERIC,
|
description: copy.refillDescription,
|
||||||
id: 'auto_reload',
|
id: 'auto_reload',
|
||||||
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
|
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
|
||||||
title: 'Refill when low'
|
title: copy.refillWhenLow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!autoReload.enabled) {
|
if (!autoReload.enabled) {
|
||||||
return {
|
return {
|
||||||
caption: 'Turn on auto-refill from the portal',
|
caption: copy.turnOnRefill,
|
||||||
description: AUTO_REFILL_GENERIC,
|
description: copy.refillDescription,
|
||||||
id: 'auto_reload',
|
id: 'auto_reload',
|
||||||
pill: { label: 'Off', tone: 'muted' },
|
pill: { label: copy.off, tone: 'muted' },
|
||||||
title: 'Refill when low'
|
title: copy.refillWhenLow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,16 +640,16 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
|
|||||||
// the default enabled path below — the same treatment as a canonical card.
|
// the default enabled path below — the same treatment as a canonical card.
|
||||||
if (autoReload.card?.kind === 'distinct') {
|
if (autoReload.card?.kind === 'distinct') {
|
||||||
const { brand, last4 } = autoReload.card
|
const { brand, last4 } = autoReload.card
|
||||||
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card'
|
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : copy.anotherCard
|
||||||
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
|
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: { label: 'Reconcile ↗', url: portalUrl },
|
action: { label: copy.reconcile, url: portalUrl },
|
||||||
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
|
caption: copy.reconcileCard(cardLabel),
|
||||||
description: AUTO_REFILL_GENERIC,
|
description: copy.refillDescription,
|
||||||
id: 'auto_reload',
|
id: 'auto_reload',
|
||||||
pill: { label: 'Enabled', tone: 'primary' },
|
pill: { label: copy.enabled, tone: 'primary' },
|
||||||
title: 'Refill when low'
|
title: copy.refillWhenLow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,22 +657,23 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
|
|||||||
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
|
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: { label: 'Manage' },
|
action: { label: copy.manage },
|
||||||
// Numbers live in the first sentence (spec §8); the swap region below carries
|
// Numbers live in the first sentence (spec §8); the swap region below carries
|
||||||
// the editable fields, so no redundant caption here.
|
// the editable fields, so no redundant caption here.
|
||||||
description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`,
|
description: copy.chargesBelow(reloadTo, threshold),
|
||||||
id: 'auto_reload',
|
id: 'auto_reload',
|
||||||
// The only row that edits in place — AutoReloadRow keys its swap layout off this
|
// The only row that edits in place — AutoReloadRow keys its swap layout off this
|
||||||
// flag rather than sniffing the action label.
|
// flag rather than sniffing the action label.
|
||||||
manageInApp: true,
|
manageInApp: true,
|
||||||
pill: { label: 'Enabled', tone: 'primary' },
|
pill: { label: copy.enabled, tone: 'primary' },
|
||||||
title: 'Refill when low'
|
title: copy.refillWhenLow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveUsageRows(
|
function deriveUsageRows(
|
||||||
billing: BillingStateResponse,
|
billing: BillingStateResponse,
|
||||||
subscription: null | SubscriptionStateResponse
|
subscription: null | SubscriptionStateResponse,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
): BillingUsageRowView[] {
|
): BillingUsageRowView[] {
|
||||||
const rows: BillingUsageRowView[] = []
|
const rows: BillingUsageRowView[] = []
|
||||||
const current = subscription?.current
|
const current = subscription?.current
|
||||||
@@ -671,8 +686,8 @@ function deriveUsageRows(
|
|||||||
const subscriptionValue =
|
const subscriptionValue =
|
||||||
remaining != null && monthly != null
|
remaining != null && monthly != null
|
||||||
? remaining < 0
|
? remaining < 0
|
||||||
? `${formatMoney(0)} of ${formatMoney(monthly)} left · ${formatMoney(Math.abs(remaining))} over`
|
? copy.remaining(formatMoney(0), formatMoney(monthly), formatMoney(Math.abs(remaining)))
|
||||||
: `${formatMoney(remaining)} of ${formatMoney(monthly)} left`
|
: copy.remaining(formatMoney(remaining), formatMoney(monthly))
|
||||||
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
|
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
|
||||||
|
|
||||||
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
|
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
|
||||||
@@ -681,16 +696,16 @@ function deriveUsageRows(
|
|||||||
bar:
|
bar:
|
||||||
remainingFraction != null
|
remainingFraction != null
|
||||||
? {
|
? {
|
||||||
label: 'Subscription credits remaining',
|
label: copy.subscriptionRemaining,
|
||||||
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
|
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
|
||||||
tone: 'subscription',
|
tone: 'subscription',
|
||||||
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
|
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
|
||||||
value: clamp01(remainingFraction)
|
value: clamp01(remainingFraction)
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
caption: `Resets ${formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)}`,
|
caption: copy.resetsOn(formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at, copy)),
|
||||||
id: 'subscription_credits',
|
id: 'subscription_credits',
|
||||||
title: 'Subscription credits',
|
title: copy.subscriptionCredits,
|
||||||
value: subscriptionValue
|
value: subscriptionValue
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -699,9 +714,9 @@ function deriveUsageRows(
|
|||||||
// No bar: top-ups have no denominator (the wire carries only the current
|
// No bar: top-ups have no denominator (the wire carries only the current
|
||||||
// balance, and the pool is open-ended), so a fill fraction would be fiction.
|
// balance, and the pool is open-ended), so a fill fraction would be fiction.
|
||||||
rows.push({
|
rows.push({
|
||||||
caption: 'Does not expire',
|
caption: copy.noExpiry,
|
||||||
id: 'topup_credits',
|
id: 'topup_credits',
|
||||||
title: 'Top-up credits',
|
title: copy.topupCredits,
|
||||||
value: topupValue
|
value: topupValue
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -711,22 +726,22 @@ function deriveUsageRows(
|
|||||||
const limit = parseAmount(cap.limit_usd)
|
const limit = parseAmount(cap.limit_usd)
|
||||||
const spent = parseAmount(cap.spent_this_month_usd) ?? 0
|
const spent = parseAmount(cap.spent_this_month_usd) ?? 0
|
||||||
const usedFraction = limit != null && limit > 0 ? spent / limit : null
|
const usedFraction = limit != null && limit > 0 ? spent / limit : null
|
||||||
const value = `${cap.spent_display || formatMoney(spent)} of ${cap.limit_display || formatMoney(limit)} used`
|
const value = copy.used(cap.spent_display || formatMoney(spent), cap.limit_display || formatMoney(limit))
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
bar:
|
bar:
|
||||||
usedFraction != null
|
usedFraction != null
|
||||||
? {
|
? {
|
||||||
label: 'Monthly spend cap used',
|
label: copy.capUsed,
|
||||||
state: usedFraction >= 0.9 ? 'danger' : 'ok',
|
state: usedFraction >= 0.9 ? 'danger' : 'ok',
|
||||||
tone: 'cap',
|
tone: 'cap',
|
||||||
track: usedFraction >= 1 ? 'danger' : undefined,
|
track: usedFraction >= 1 ? 'danger' : undefined,
|
||||||
value: clamp01(usedFraction)
|
value: clamp01(usedFraction)
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
|
caption: cap.is_default_ceiling ? copy.defaultCeiling : copy.monthlyRemoteSpending,
|
||||||
id: 'monthly_cap',
|
id: 'monthly_cap',
|
||||||
title: 'Monthly spend cap',
|
title: copy.monthlyCap,
|
||||||
value
|
value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -738,7 +753,11 @@ function displayBalance(billing: BillingStateResponse): string {
|
|||||||
return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd)
|
return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd)
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayPlan(subscription: null | SubscriptionStateResponse, usage?: UsageModelData): string {
|
function displayPlan(
|
||||||
|
subscription: null | SubscriptionStateResponse,
|
||||||
|
usage?: UsageModelData,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): string {
|
||||||
const current = subscription?.current
|
const current = subscription?.current
|
||||||
const tier = current?.tier_name ?? usage?.plan_name
|
const tier = current?.tier_name ?? usage?.plan_name
|
||||||
|
|
||||||
@@ -748,7 +767,7 @@ function displayPlan(subscription: null | SubscriptionStateResponse, usage?: Usa
|
|||||||
|
|
||||||
const price = findCurrentTier(subscription)?.dollars_per_month_display
|
const price = findCurrentTier(subscription)?.dollars_per_month_display
|
||||||
|
|
||||||
return price ? `${tier} · ${price}/mo` : tier
|
return price ? `${tier} · ${price}${copy.monthSuffix}` : tier
|
||||||
}
|
}
|
||||||
|
|
||||||
function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData): string {
|
function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData): string {
|
||||||
@@ -760,33 +779,40 @@ function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buyCreditsDisabledReason(billing: BillingStateResponse): null | string {
|
function buyCreditsDisabledReason(
|
||||||
|
billing: BillingStateResponse,
|
||||||
|
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||||
|
): null | string {
|
||||||
if (!billing.is_admin) {
|
if (!billing.is_admin) {
|
||||||
return resolveRefusal({ kind: 'role_required', message: '' }).message
|
return resolveRefusal({ kind: 'role_required', message: '' }, copy).message
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!billing.cli_billing_enabled) {
|
if (!billing.cli_billing_enabled) {
|
||||||
return resolveRefusal({ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
|
return resolveRefusal(
|
||||||
.message
|
{ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
|
||||||
|
copy
|
||||||
|
).message
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!billing.can_charge) {
|
if (!billing.can_charge) {
|
||||||
return resolveRefusal({ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
|
return resolveRefusal(
|
||||||
.message
|
{ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
|
||||||
|
copy
|
||||||
|
).message
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function provenanceSuffix(resolvedVia?: null | string): string {
|
function provenanceSuffix(resolvedVia?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
|
||||||
if (!resolvedVia) {
|
if (!resolvedVia) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
autoRefill: 'auto-refill card',
|
autoRefill: copy.refillCard,
|
||||||
customerDefault: 'customer default',
|
customerDefault: copy.customerDefault,
|
||||||
subPin: 'subscription card'
|
subPin: copy.subscriptionCard
|
||||||
}
|
}
|
||||||
|
|
||||||
return ` - ${labels[resolvedVia] ?? resolvedVia}`
|
return ` - ${labels[resolvedVia] ?? resolvedVia}`
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { act, renderHook } from '@testing-library/react'
|
|||||||
import { createElement, type PropsWithChildren } from 'react'
|
import { createElement, type PropsWithChildren } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n'
|
||||||
|
|
||||||
import type { BillingResult } from './api'
|
import type { BillingResult } from './api'
|
||||||
import type { BillingChargeStatusResponse } from './types'
|
import type { BillingChargeStatusResponse } from './types'
|
||||||
|
|
||||||
@@ -58,7 +60,11 @@ function controlledClock() {
|
|||||||
function wrapper({ children }: PropsWithChildren) {
|
function wrapper({ children }: PropsWithChildren) {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
||||||
return createElement(QueryClientProvider, { client }, children)
|
return createElement(I18nProvider, {
|
||||||
|
configClient: null,
|
||||||
|
initialLocale: 'en',
|
||||||
|
children: createElement(QueryClientProvider, { client }, children)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCallback, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import type { BillingApi, BillingRefusal } from './api'
|
import type { BillingApi, BillingRefusal } from './api'
|
||||||
import { useBillingApi } from './api'
|
import { useBillingApi } from './api'
|
||||||
|
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
|
||||||
import { resolveRefusal } from './errors'
|
import { resolveRefusal } from './errors'
|
||||||
import type { BillingChargeStatusResponse } from './types'
|
import type { BillingChargeStatusResponse } from './types'
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export interface ChargePollClock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChargePollOptions extends ChargePollClock {
|
export interface ChargePollOptions extends ChargePollClock {
|
||||||
|
copy?: BillingCopy
|
||||||
portalUrl?: null | string
|
portalUrl?: null | string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ export async function pollChargeSettlement(
|
|||||||
chargeId: string,
|
chargeId: string,
|
||||||
opts: ChargePollOptions = {}
|
opts: ChargePollOptions = {}
|
||||||
): Promise<ChargeFlowOutcome> {
|
): Promise<ChargeFlowOutcome> {
|
||||||
|
const copy = opts.copy ?? DEFAULT_BILLING_COPY
|
||||||
const sleep = opts.sleep ?? defaultSleep
|
const sleep = opts.sleep ?? defaultSleep
|
||||||
const now = opts.now ?? Date.now
|
const now = opts.now ?? Date.now
|
||||||
const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {}
|
const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {}
|
||||||
@@ -96,51 +99,51 @@ export async function pollChargeSettlement(
|
|||||||
return {
|
return {
|
||||||
amountUsd: settlement.status.amount_usd,
|
amountUsd: settlement.status.amount_usd,
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
message: settlement.status.amount_usd ? `$${settlement.status.amount_usd} added.` : 'Credits added.'
|
message: settlement.status.amount_usd ? copy.chargeAdded(settlement.status.amount_usd) : copy.creditsAdded
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return {
|
return {
|
||||||
action: { type: 'retry' },
|
action: { type: 'retry' },
|
||||||
kind: 'failure',
|
kind: 'failure',
|
||||||
message: renderChargeFailed(settlement.status.reason),
|
message: renderChargeFailed(settlement.status.reason, copy),
|
||||||
retryFreshKey: true,
|
retryFreshKey: true,
|
||||||
title: 'Charge failed'
|
title: copy.chargeFailedTitle
|
||||||
}
|
}
|
||||||
case 'ambiguous': {
|
case 'ambiguous': {
|
||||||
if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) {
|
if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) {
|
||||||
const refusal = observed.refusal ?? refusalFromStatus(settlement.error, settlement.status)
|
const refusal = observed.refusal ?? refusalFromStatus(settlement.error, settlement.status)
|
||||||
const resolved = resolveRefusal(refusal)
|
const resolved = resolveRefusal(refusal, copy)
|
||||||
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : refusal.portalUrl
|
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : refusal.portalUrl
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'ambiguous',
|
kind: 'ambiguous',
|
||||||
message: `${resolved.message} Your last charge's outcome is unconfirmed - check your balance/history before retrying.`,
|
message: copy.unconfirmed(resolved.message),
|
||||||
portalUrl: portalUrl ?? opts.portalUrl ?? undefined,
|
portalUrl: portalUrl ?? opts.portalUrl ?? undefined,
|
||||||
title: 'Charge outcome unconfirmed'
|
title: copy.unconfirmedTitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'failure',
|
kind: 'failure',
|
||||||
message: observed.refusal?.message || 'Could not check the charge.',
|
message: observed.refusal?.message || copy.checkFailedMessage,
|
||||||
retryFreshKey: true,
|
retryFreshKey: true,
|
||||||
title: 'Could not check charge'
|
title: copy.checkFailedTitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'refused':
|
case 'refused':
|
||||||
return {
|
return {
|
||||||
kind: 'failure',
|
kind: 'failure',
|
||||||
message: observed.refusal?.message || settlement.status.message || 'Could not check the charge.',
|
message: observed.refusal?.message || settlement.status.message || copy.checkFailedMessage,
|
||||||
retryFreshKey: true,
|
retryFreshKey: true,
|
||||||
title: 'Could not check charge'
|
title: copy.checkFailedTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
|
|
||||||
case 'timed_out':
|
case 'timed_out':
|
||||||
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl)
|
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl, copy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +176,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useChargeFlow() {
|
export function useChargeFlow() {
|
||||||
|
const copy = useBillingCopy()
|
||||||
const api = useBillingApi()
|
const api = useBillingApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [phase, setPhase] = useState<ChargeFlowPhase>('idle')
|
const [phase, setPhase] = useState<ChargeFlowPhase>('idle')
|
||||||
@@ -206,7 +210,7 @@ export function useChargeFlow() {
|
|||||||
const chargeResult = await api.charge(amountUsd, idempotencyKey)
|
const chargeResult = await api.charge(amountUsd, idempotencyKey)
|
||||||
|
|
||||||
if (!chargeResult.ok) {
|
if (!chargeResult.ok) {
|
||||||
const resolved = resolveRefusal(chargeResult.refusal)
|
const resolved = resolveRefusal(chargeResult.refusal, copy)
|
||||||
|
|
||||||
const action =
|
const action =
|
||||||
resolved.action.type === 'portal'
|
resolved.action.type === 'portal'
|
||||||
@@ -239,9 +243,9 @@ export function useChargeFlow() {
|
|||||||
if (!chargeId) {
|
if (!chargeId) {
|
||||||
setOutcome({
|
setOutcome({
|
||||||
kind: 'failure',
|
kind: 'failure',
|
||||||
message: 'The billing service accepted the request but did not return a charge id.',
|
message: copy.missingChargeId,
|
||||||
retryFreshKey: true,
|
retryFreshKey: true,
|
||||||
title: 'Charge could not be tracked'
|
title: copy.untrackedTitle
|
||||||
})
|
})
|
||||||
setPhaseState('done')
|
setPhaseState('done')
|
||||||
|
|
||||||
@@ -251,6 +255,7 @@ export function useChargeFlow() {
|
|||||||
setPhaseState('polling')
|
setPhaseState('polling')
|
||||||
|
|
||||||
const pollOutcome = await pollChargeSettlement(api, chargeId, {
|
const pollOutcome = await pollChargeSettlement(api, chargeId, {
|
||||||
|
copy,
|
||||||
portalUrl: chargeResult.data.portal_url
|
portalUrl: chargeResult.data.portal_url
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,7 +266,7 @@ export function useChargeFlow() {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, queryClient, setPhaseState]
|
[api, queryClient, setPhaseState, copy]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { outcome, phase, reset, start }
|
return { outcome, phase, reset, start }
|
||||||
@@ -271,27 +276,27 @@ function shouldReuseIdempotencyKey(refusal: BillingRefusal): boolean {
|
|||||||
return retryableSendKinds.has(refusal.kind)
|
return retryableSendKinds.has(refusal.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeoutOutcome(portalUrl?: null | string): ChargeFlowOutcome {
|
function timeoutOutcome(portalUrl?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): ChargeFlowOutcome {
|
||||||
return {
|
return {
|
||||||
kind: 'ambiguous',
|
kind: 'ambiguous',
|
||||||
message: 'Charge may still settle. Check the portal before retrying.',
|
message: copy.stillProcessingMessage,
|
||||||
portalUrl: portalUrl ?? undefined,
|
portalUrl: portalUrl ?? undefined,
|
||||||
title: 'Still processing after 5 minutes'
|
title: copy.stillProcessingTitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChargeFailed(reason?: null | string): string {
|
function renderChargeFailed(reason?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
|
||||||
switch ((reason || '').trim()) {
|
switch ((reason || '').trim()) {
|
||||||
case 'authentication_required':
|
case 'authentication_required':
|
||||||
return 'Your bank requires verification (3DS). Complete it on the portal to finish this purchase.'
|
return copy.bankVerification
|
||||||
|
|
||||||
case 'payment_method_expired':
|
case 'payment_method_expired':
|
||||||
return 'Your card has expired. Update it on the portal.'
|
return copy.cardExpired
|
||||||
|
|
||||||
case 'card_declined':
|
case 'card_declined':
|
||||||
return 'Your card was declined. Try another card on the portal.'
|
return copy.cardDeclined
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return `The charge didn't go through (${reason || 'processing_error'}).`
|
return copy.chargeFailed(reason || 'processing_error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'
|
|||||||
import { createElement, type PropsWithChildren } from 'react'
|
import { createElement, type PropsWithChildren } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n'
|
||||||
|
|
||||||
const apiMocks = vi.hoisted(() => ({
|
const apiMocks = vi.hoisted(() => ({
|
||||||
stepUp: vi.fn()
|
stepUp: vi.fn()
|
||||||
}))
|
}))
|
||||||
@@ -56,7 +58,11 @@ import { useStepUpFlow } from './use-step-up'
|
|||||||
|
|
||||||
function createWrapper(client: QueryClient) {
|
function createWrapper(client: QueryClient) {
|
||||||
return function wrapper({ children }: PropsWithChildren) {
|
return function wrapper({ children }: PropsWithChildren) {
|
||||||
return createElement(QueryClientProvider, { client }, children)
|
return createElement(I18nProvider, {
|
||||||
|
configClient: null,
|
||||||
|
initialLocale: 'en',
|
||||||
|
children: createElement(QueryClientProvider, { client }, children)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { $gateway } from '@/store/gateway'
|
import { $gateway } from '@/store/gateway'
|
||||||
|
|
||||||
import { useBillingApi } from './api'
|
import { useBillingApi } from './api'
|
||||||
|
import { useBillingCopy } from './copy'
|
||||||
import { resolveRefusal } from './errors'
|
import { resolveRefusal } from './errors'
|
||||||
|
|
||||||
export type StepUpPhase = 'idle' | 'verifying' | 'waiting'
|
export type StepUpPhase = 'idle' | 'verifying' | 'waiting'
|
||||||
@@ -26,6 +27,7 @@ interface StepUpVerificationPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useStepUpFlow() {
|
export function useStepUpFlow() {
|
||||||
|
const copy = useBillingCopy()
|
||||||
const api = useBillingApi()
|
const api = useBillingApi()
|
||||||
const gateway = useStore($gateway)
|
const gateway = useStore($gateway)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -107,7 +109,7 @@ export function useStepUpFlow() {
|
|||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const resolved = resolveRefusal(result.refusal)
|
const resolved = resolveRefusal(result.refusal, copy)
|
||||||
|
|
||||||
setMessage({
|
setMessage({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
@@ -121,8 +123,8 @@ export function useStepUpFlow() {
|
|||||||
if (!result.data.granted) {
|
if (!result.data.granted) {
|
||||||
setMessage({
|
setMessage({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
text: 'Verification finished without allowing Remote Spending for this terminal.',
|
text: copy.verificationDeniedMessage,
|
||||||
title: 'Verification was not approved'
|
title: copy.verificationDeniedTitle
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -134,10 +136,10 @@ export function useStepUpFlow() {
|
|||||||
])
|
])
|
||||||
setMessage({
|
setMessage({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
text: 'Remote Spending is allowed for this terminal.',
|
text: copy.verificationDoneMessage,
|
||||||
title: 'Verification complete'
|
title: copy.verificationDoneTitle
|
||||||
})
|
})
|
||||||
}, [api, gateway, queryClient, unsubscribe])
|
}, [api, gateway, queryClient, unsubscribe, copy])
|
||||||
|
|
||||||
return { dismiss, message, openVerification, phase, start, verification }
|
return { dismiss, message, openVerification, phase, start, verification }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,7 @@ export function ComboboxInput({
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ export function ComboboxInput({
|
|||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
aria-label="Show options"
|
aria-label={t.settings.runtime.showOptions}
|
||||||
className="absolute inset-y-0 right-1.5 flex items-center text-muted-foreground"
|
className="absolute inset-y-0 right-1.5 flex items-center text-muted-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(current => !current)
|
setOpen(current => !current)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { cleanup, render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n/context'
|
||||||
|
|
||||||
|
import { ComputerUsePanel } from './computer-use-panel'
|
||||||
|
|
||||||
|
const getStatus = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('@/hermes', () => ({
|
||||||
|
getComputerUseStatus: getStatus,
|
||||||
|
getActionStatus: vi.fn(),
|
||||||
|
grantComputerUsePermissions: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Computer Use localized permissions', () => {
|
||||||
|
it('renders macOS permission labels, explanations, and states in Turkish', async () => {
|
||||||
|
getStatus.mockResolvedValue({
|
||||||
|
platform: 'darwin',
|
||||||
|
platform_supported: true,
|
||||||
|
installed: true,
|
||||||
|
can_grant: true,
|
||||||
|
accessibility: true,
|
||||||
|
screen_recording: false,
|
||||||
|
ready: false,
|
||||||
|
checks: []
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<I18nProvider configClient={null} initialLocale="tr">
|
||||||
|
<ComputerUsePanel />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('Erişilebilirlik')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Ekran Kaydı')).toBeTruthy()
|
||||||
|
expect(screen.getByText('İzin verildi')).toBeTruthy()
|
||||||
|
expect(screen.getByText('İzin verilmedi')).toBeTruthy()
|
||||||
|
expect(screen.getByText(/uygulama pencerelerinin ekran görüntüsünü/)).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'İzinleri ver' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains English and represents a ready Windows driver without macOS permission controls', async () => {
|
||||||
|
getStatus.mockResolvedValue({
|
||||||
|
platform: 'win32',
|
||||||
|
platform_supported: true,
|
||||||
|
installed: true,
|
||||||
|
can_grant: false,
|
||||||
|
ready: true,
|
||||||
|
checks: []
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
|
<ComputerUsePanel />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('Driver health')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Ready')).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Grant permissions' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
|
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
|
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
|
||||||
import { upsertDesktopActionTask } from '@/store/activity'
|
import { upsertDesktopActionTask } from '@/store/activity'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
@@ -17,10 +18,6 @@ interface ComputerUsePanelProps {
|
|||||||
|
|
||||||
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
|
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
|
||||||
// drives the permission rows instead, so it has no entry here.
|
// drives the permission rows instead, so it has no entry here.
|
||||||
const PLATFORM_NOTE: Record<string, string> = {
|
|
||||||
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
|
|
||||||
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker — allow it.'
|
|
||||||
}
|
|
||||||
|
|
||||||
function tone(granted: boolean | null) {
|
function tone(granted: boolean | null) {
|
||||||
return granted === true ? 'primary' : 'muted'
|
return granted === true ? 'primary' : 'muted'
|
||||||
@@ -33,6 +30,9 @@ function GrantIcon({ granted }: { granted: boolean | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
|
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.runtime.computerUse
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -41,7 +41,7 @@ function PermissionRow({ granted, label, hint }: { granted: boolean | null; labe
|
|||||||
</div>
|
</div>
|
||||||
<Pill tone={tone(granted)}>
|
<Pill tone={tone(granted)}>
|
||||||
<GrantIcon granted={granted} />
|
<GrantIcon granted={granted} />
|
||||||
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
|
{granted === true ? copy.granted : granted === false ? copy.notGranted : copy.unknown}
|
||||||
</Pill>
|
</Pill>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -61,6 +61,8 @@ function PermissionRow({ granted, label, hint }: { granted: boolean | null; labe
|
|||||||
* below this card (the generic ToolsetConfigPanel).
|
* below this card (the generic ToolsetConfigPanel).
|
||||||
*/
|
*/
|
||||||
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
|
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.runtime.computerUse
|
||||||
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
|
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [granting, setGranting] = useState(false)
|
const [granting, setGranting] = useState(false)
|
||||||
@@ -70,11 +72,11 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||||||
try {
|
try {
|
||||||
setStatus(await getComputerUseStatus())
|
setStatus(await getComputerUseStatus())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Could not read Computer Use status')
|
notifyError(err, copy.statusFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [copy.statusFailed])
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,15 +93,15 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||||||
const started = await grantComputerUsePermissions()
|
const started = await grantComputerUsePermissions()
|
||||||
|
|
||||||
if (!started.ok) {
|
if (!started.ok) {
|
||||||
notifyError(new Error('spawn failed'), 'Could not request permissions')
|
notifyError(new Error('spawn failed'), copy.permissionsFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
kind: 'info',
|
kind: 'info',
|
||||||
title: 'Approve in System Settings',
|
title: copy.approveTitle,
|
||||||
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
|
message: copy.approveBody
|
||||||
})
|
})
|
||||||
|
|
||||||
// The driver waits for the user to flip the switch — poll until it exits.
|
// The driver waits for the user to flip the switch — poll until it exits.
|
||||||
@@ -124,20 +126,20 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (activeRef.current) {
|
if (activeRef.current) {
|
||||||
notifyError(err, 'Could not request permissions')
|
notifyError(err, copy.permissionsFailed)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (activeRef.current) {
|
if (activeRef.current) {
|
||||||
setGranting(false)
|
setGranting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onConfiguredChange, refresh])
|
}, [onConfiguredChange, refresh, copy])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
Checking Computer Use status…
|
{copy.checking}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,18 +149,14 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!status.platform_supported) {
|
if (!status.platform_supported) {
|
||||||
return (
|
return <p className="px-1 text-xs text-muted-foreground">{copy.unsupported(status.platform)}</p>
|
||||||
<p className="px-1 text-xs text-muted-foreground">
|
|
||||||
Computer Use isn't supported on this platform ({status.platform}).
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!status.installed) {
|
if (!status.installed) {
|
||||||
return (
|
return (
|
||||||
<p className="px-1 text-xs text-muted-foreground">
|
<p className="px-1 text-xs text-muted-foreground">
|
||||||
Install the cua-driver backend below to drive this machine.
|
{copy.install}
|
||||||
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
|
{status.can_grant && copy.installGrant}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -170,40 +168,35 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{status.can_grant ? (
|
{status.can_grant ? (
|
||||||
<p className="text-[0.72rem] text-muted-foreground">
|
<p className="text-[0.72rem] text-muted-foreground">{copy.identity}</p>
|
||||||
Grants attach to CuaDriver's own identity (com.trycua.driver), not Hermes — so the dialog is
|
|
||||||
attributed to the process that drives your Mac.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[0.72rem] text-muted-foreground">{PLATFORM_NOTE[status.platform] ?? ''}</p>
|
<p className="text-[0.72rem] text-muted-foreground">
|
||||||
|
{status.platform === 'linux' ? copy.linux : status.platform === 'win32' ? copy.win32 : ''}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
|
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => void refresh()} size="sm" variant="text">
|
<Button onClick={() => void refresh()} size="sm" variant="text">
|
||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
Recheck
|
{copy.recheck}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status.can_grant ? (
|
{status.can_grant ? (
|
||||||
<>
|
<>
|
||||||
<PermissionRow
|
<PermissionRow granted={status.accessibility} hint={copy.accessibilityHint} label={copy.accessibility} />
|
||||||
granted={status.accessibility}
|
|
||||||
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
|
|
||||||
label="Accessibility"
|
|
||||||
/>
|
|
||||||
<PermissionRow
|
<PermissionRow
|
||||||
granted={status.screen_recording}
|
granted={status.screen_recording}
|
||||||
hint="Lets cua-driver capture screenshots of app windows."
|
hint={copy.screenRecordingHint}
|
||||||
label="Screen Recording"
|
label={copy.screenRecording}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
||||||
<span className="text-sm font-medium">Driver health</span>
|
<span className="text-sm font-medium">{copy.driverHealth}</span>
|
||||||
<Pill tone={tone(status.ready)}>
|
<Pill tone={tone(status.ready)}>
|
||||||
<GrantIcon granted={status.ready} />
|
<GrantIcon granted={status.ready} />
|
||||||
{status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'}
|
{status.ready === true ? copy.ready : status.ready === false ? copy.notReady : copy.unknown}
|
||||||
</Pill>
|
</Pill>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -225,13 +218,13 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||||||
{status.ready ? (
|
{status.ready ? (
|
||||||
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
|
||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
Computer Use is ready. Ask the agent to capture an app and click around.
|
{copy.readyHint}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
status.can_grant && (
|
status.can_grant && (
|
||||||
<Button disabled={granting} onClick={() => void grant()} size="sm">
|
<Button disabled={granting} onClick={() => void grant()} size="sm">
|
||||||
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
|
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
|
||||||
{granting ? 'Waiting for approval…' : 'Grant permissions'}
|
{granting ? copy.waiting : copy.grant}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function ConfigField({
|
|||||||
{selectOptions.map(option => (
|
{selectOptions.map(option => (
|
||||||
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
|
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
|
||||||
{option
|
{option
|
||||||
? (optionLabels?.[option] ?? prettyName(option))
|
? (optionLabels?.[option] ?? t.settings.fieldOptions?.[schemaKey]?.[option] ?? prettyName(option))
|
||||||
: schemaKey === 'display.personality'
|
: schemaKey === 'display.personality'
|
||||||
? c.none
|
? c.none
|
||||||
: schemaKey === 'memory.provider'
|
: schemaKey === 'memory.provider'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import type { DesktopConnectionsRegistry } from '@/global'
|
import type { DesktopConnectionsRegistry } from '@/global'
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sameBackendPeerLabel,
|
sameBackendPeerLabel,
|
||||||
sshCompositeKey
|
sshCompositeKey
|
||||||
} from './connections-registry'
|
} from './connections-registry'
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
const list = vi.fn()
|
const list = vi.fn()
|
||||||
const save = vi.fn()
|
const save = vi.fn()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import type { EnvVarInfo } from '@/types/hermes'
|
import type { EnvVarInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { CONTROL_TEXT } from './constants'
|
import { CONTROL_TEXT } from './constants'
|
||||||
import { prettyName, withoutKey } from './helpers'
|
import { prettyName, providerDescription, withoutKey } from './helpers'
|
||||||
import { ListRow } from './primitives'
|
import { ListRow } from './primitives'
|
||||||
import type { EnvRowProps } from './types'
|
import type { EnvRowProps } from './types'
|
||||||
|
|
||||||
@@ -254,9 +254,9 @@ export function CredentialKeyCard({
|
|||||||
|
|
||||||
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
|
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
|
||||||
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
|
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const docsUrl = group.docsUrl?.trim()
|
const docsUrl = group.docsUrl?.trim()
|
||||||
const description = group.description?.trim()
|
const description = providerDescription(group.name, group.description, locale)
|
||||||
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
|
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
|
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
const getConnectionConfig = vi.fn()
|
const getConnectionConfig = vi.fn()
|
||||||
const saveConnectionConfig = vi.fn()
|
const saveConnectionConfig = vi.fn()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TR_PROVIDER_DESCRIPTIONS } from '@/i18n/tr-provider-descriptions'
|
||||||
import { asText, normalize } from '@/lib/text'
|
import { asText, normalize } from '@/lib/text'
|
||||||
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||||
|
|
||||||
@@ -50,6 +51,14 @@ export const providerMeta = (name: string) =>
|
|||||||
|
|
||||||
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
|
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
|
||||||
|
|
||||||
|
export function providerDescription(name: string, description: string | undefined, locale: string): string | undefined {
|
||||||
|
const original = description?.trim()
|
||||||
|
|
||||||
|
return locale === 'tr' && original && original === providerMeta(name)?.description
|
||||||
|
? (TR_PROVIDER_DESCRIPTIONS[name] ?? original)
|
||||||
|
: original
|
||||||
|
}
|
||||||
|
|
||||||
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
|
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||||
|
|
||||||
function isSafePart(part: string): boolean {
|
function isSafePart(part: string): boolean {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const REFUSED_MODEL: LocalCatalogModel = {
|
|||||||
function renderPane() {
|
function renderPane() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<I18nProvider>
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
<LocalModelsSettings />
|
<LocalModelsSettings />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -429,7 +429,7 @@ describe('BrowseSection', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<I18nProvider>
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
<LocalModelsSettings />
|
<LocalModelsSettings />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@@ -534,7 +534,7 @@ describe('quickstart completion navigation', () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={['/settings']}>
|
<MemoryRouter initialEntries={['/settings']}>
|
||||||
<I18nProvider>
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
<LocalModelsSettings />
|
<LocalModelsSettings />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
<Probe />
|
<Probe />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
|
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
|
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
|
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
|
||||||
@@ -13,6 +14,8 @@ const POLL_TIMEOUT_MS = 120_000
|
|||||||
// backend-driven: the status route 404s for providers without an oauth_flow
|
// backend-driven: the status route 404s for providers without an oauth_flow
|
||||||
// module, so non-OAuth providers render nothing.
|
// module, so non-OAuth providers render nothing.
|
||||||
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
|
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.runtime.memory
|
||||||
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
|
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
|
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
|
||||||
@@ -75,8 +78,8 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
|||||||
await startMemoryProviderOAuth(provider, profile)
|
await startMemoryProviderOAuth(provider, profile)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
setDetail('Could not start the connection.')
|
setDetail(copy.startFailed)
|
||||||
notifyError(err, 'Failed to start connection')
|
notifyError(err, copy.startFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -92,7 +95,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
|||||||
if (Date.now() > deadline.current) {
|
if (Date.now() > deadline.current) {
|
||||||
stop()
|
stop()
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
setDetail('Timed out — try again.')
|
setDetail(copy.timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -104,7 +107,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
|||||||
|
|
||||||
if (next.state === 'error') {
|
if (next.state === 'error') {
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
setDetail(next.detail || 'Connection failed.')
|
setDetail(next.detail || copy.connectionFailed)
|
||||||
} else {
|
} else {
|
||||||
setPhase('idle')
|
setPhase('idle')
|
||||||
}
|
}
|
||||||
@@ -113,7 +116,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, POLL_MS)
|
}, POLL_MS)
|
||||||
}, [profile, provider, stop])
|
}, [profile, provider, stop, copy])
|
||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
stop()
|
stop()
|
||||||
@@ -124,24 +127,24 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
|
const connectLabel = connected ? (auth === 'apikey' ? copy.connectOauth : copy.reconnect) : copy.connect
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||||
{phase === 'idle' && connected && (
|
{phase === 'idle' && connected && (
|
||||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
<Check className="size-3" />
|
<Check className="size-3" />
|
||||||
{auth === 'apikey' ? 'api key set' : 'oauth set'}
|
{auth === 'apikey' ? copy.apiKeySet : copy.oauthSet}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{phase === 'pending' ? (
|
{phase === 'pending' ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
Waiting for browser consent…
|
{copy.waiting}
|
||||||
</span>
|
</span>
|
||||||
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
|
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Check, Info } from '@/lib/icons'
|
import { Check, Info } from '@/lib/icons'
|
||||||
import type { MemoryProviderField } from '@/types/hermes'
|
import type { MemoryProviderField } from '@/types/hermes'
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground
|
|||||||
|
|
||||||
// Field label with an optional info tooltip, shared by the panel and modal rows.
|
// Field label with an optional info tooltip, shared by the panel and modal rows.
|
||||||
export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
if (!field.info) {
|
if (!field.info) {
|
||||||
return <>{field.label}</>
|
return <>{field.label}</>
|
||||||
}
|
}
|
||||||
@@ -21,7 +24,7 @@ export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
|||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{field.label}
|
{field.label}
|
||||||
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
|
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
|
||||||
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
|
<Info aria-label={t.settings.runtime.memory.about(field.label)} className="size-3.5 text-muted-foreground/70" />
|
||||||
</Tip>
|
</Tip>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -41,6 +44,8 @@ export function FieldControl({
|
|||||||
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
|
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
|
||||||
onCommit?: (value: string) => void
|
onCommit?: (value: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const set = (next: string) => {
|
const set = (next: string) => {
|
||||||
onChange(next)
|
onChange(next)
|
||||||
onCommit?.(next)
|
onCommit?.(next)
|
||||||
@@ -103,14 +108,14 @@ export function FieldControl({
|
|||||||
className={`w-full ${FIELD_INPUT}`}
|
className={`w-full ${FIELD_INPUT}`}
|
||||||
onBlur={commitDraft}
|
onBlur={commitDraft}
|
||||||
onChange={event => onChange(event.target.value)}
|
onChange={event => onChange(event.target.value)}
|
||||||
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
|
placeholder={field.is_set ? t.settings.runtime.memory.keepSecret : field.placeholder}
|
||||||
type="password"
|
type="password"
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
{field.is_set && (
|
{field.is_set && (
|
||||||
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
|
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
|
||||||
<Check className="size-3 text-(--ui-accent-secondary)" />
|
<Check className="size-3 text-(--ui-accent-secondary)" />
|
||||||
set
|
{t.settings.runtime.memory.set}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from '../test-locale'
|
||||||
|
|
||||||
const saveMemoryProviderConfig = vi.fn()
|
const saveMemoryProviderConfig = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { saveMemoryProviderConfig } from '@/hermes'
|
import { saveMemoryProviderConfig } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
|
||||||
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
|
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import { $activeGatewayProfile } from '@/store/profile'
|
import { $activeGatewayProfile } from '@/store/profile'
|
||||||
@@ -59,6 +61,8 @@ export function ProviderConfigModal({
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSaved: () => Promise<void> | void
|
onSaved: () => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const copy = t.settings.runtime.memory
|
||||||
const activeProfile = useStore($activeGatewayProfile)
|
const activeProfile = useStore($activeGatewayProfile)
|
||||||
const [values, setValues] = useState<Record<string, string>>({})
|
const [values, setValues] = useState<Record<string, string>>({})
|
||||||
const [seeded, setSeeded] = useState<Record<string, string>>({})
|
const [seeded, setSeeded] = useState<Record<string, string>>({})
|
||||||
@@ -81,11 +85,11 @@ export function ProviderConfigModal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await saveMemoryProviderConfig(provider, edited, profile)
|
await saveMemoryProviderConfig(provider, edited, profile)
|
||||||
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
|
notify({ kind: 'success', title: copy.saved(config.label), message: copy.updated })
|
||||||
await onSaved()
|
await onSaved()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to save ${config.label} settings`)
|
notifyError(err, copy.saveFailed(config.label))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -95,11 +99,8 @@ export function ProviderConfigModal({
|
|||||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
|
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle icon={SlidersHorizontal}>{config.label} — full configuration</DialogTitle>
|
<DialogTitle icon={SlidersHorizontal}>{copy.fullTitle(config.label)}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{copy.fullDescription(config.label, profile ?? activeProfile)}</DialogDescription>
|
||||||
Every {config.label} option for the <span className="font-medium">{profile ?? activeProfile}</span> profile.
|
|
||||||
Blank fields fall back to the resolved host or built-in default.
|
|
||||||
</DialogDescription>
|
|
||||||
{config.docs_url && (
|
{config.docs_url && (
|
||||||
<a
|
<a
|
||||||
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
|
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
|
||||||
@@ -111,17 +112,18 @@ export function ProviderConfigModal({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{config.label} configuration reference
|
{copy.reference(config.label)}
|
||||||
<ExternalLink className="size-3" />
|
<ExternalLink className="size-3" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{groupFields(config.fields).map(([group, fields]) => (
|
{groupFields(config.fields.map(field => localizeMemoryField(config.name, field, locale))).map(
|
||||||
|
([group, fields]) => (
|
||||||
<section className="mt-6 first:mt-2" key={group}>
|
<section className="mt-6 first:mt-2" key={group}>
|
||||||
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
|
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
|
||||||
{group}
|
{group === 'Other' ? copy.other : group}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="pl-1">
|
<div className="pl-1">
|
||||||
{fields.map(field => (
|
{fields.map(field => (
|
||||||
@@ -141,18 +143,19 @@ export function ProviderConfigModal({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button size="sm" type="button" variant="ghost">
|
<Button size="sm" type="button" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button disabled={saving} onClick={() => void save()} size="sm">
|
<Button disabled={saving} onClick={() => void save()} size="sm">
|
||||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||||
Save changes
|
{copy.saveChanges}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render as renderLocale, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n'
|
||||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from '../test-locale'
|
||||||
|
|
||||||
const getMemoryProviderConfig = vi.fn()
|
const getMemoryProviderConfig = vi.fn()
|
||||||
const saveMemoryProviderConfig = vi.fn()
|
const saveMemoryProviderConfig = vi.fn()
|
||||||
|
|
||||||
@@ -198,6 +201,24 @@ describe('ProviderConfigPanel', () => {
|
|||||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders Turkish field copy and saves the original backend key and value', async () => {
|
||||||
|
const { ProviderConfigPanel } = await import('./provider-config-panel')
|
||||||
|
renderLocale(
|
||||||
|
<I18nProvider configClient={null} initialLocale="tr">
|
||||||
|
<ProviderConfigPanel provider="honcho" />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('Çalışma alanı')).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
screen.getByText('Honcho çalışma alanı kimliği. Varsayılan olarak profilin ana bilgisayarını kullanır.')
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(screen.getByRole('combobox').textContent).toContain('Bulut')
|
||||||
|
const workspace = screen.getByDisplayValue('myws')
|
||||||
|
fireEvent.change(workspace, { target: { value: 'ekibim' } })
|
||||||
|
fireEvent.blur(workspace)
|
||||||
|
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { workspace: 'ekibim' }))
|
||||||
|
})
|
||||||
|
|
||||||
it('renders nothing for a provider with no declared config surface', async () => {
|
it('renders nothing for a provider with no declared config surface', async () => {
|
||||||
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
|
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { PageLoader } from '@/components/page-loader'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
|
||||||
import { SlidersHorizontal } from '@/lib/icons'
|
import { SlidersHorizontal } from '@/lib/icons'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||||
@@ -21,6 +23,8 @@ function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) {
|
export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) {
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const copy = t.settings.runtime.memory
|
||||||
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
||||||
const [loadError, setLoadError] = useState<null | string>(null)
|
const [loadError, setLoadError] = useState<null | string>(null)
|
||||||
const [values, setValues] = useState<Record<string, string>>({})
|
const [values, setValues] = useState<Record<string, string>>({})
|
||||||
@@ -38,9 +42,9 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
|||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setConfig(null)
|
setConfig(null)
|
||||||
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
|
setLoadError(err instanceof Error ? err.message : copy.loadFailed)
|
||||||
}
|
}
|
||||||
}, [profile, provider])
|
}, [profile, provider, copy.loadFailed])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConfig(null)
|
setConfig(null)
|
||||||
@@ -71,10 +75,10 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
|||||||
setSaved(current => ({ ...current, [field.key]: value }))
|
setSaved(current => ({ ...current, [field.key]: value }))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to save ${field.label}`)
|
notifyError(err, copy.saveFailed(field.label))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[profile, provider, saved]
|
[profile, provider, saved, copy]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Providers without a declared config surface (e.g. builtin) render nothing.
|
// Providers without a declared config surface (e.g. builtin) render nothing.
|
||||||
@@ -87,20 +91,21 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 py-2">
|
<div className="flex items-center justify-between gap-3 py-2">
|
||||||
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||||
Memory provider settings failed to load: {loadError}
|
{copy.loadFailed}: {loadError}
|
||||||
</span>
|
</span>
|
||||||
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
|
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
|
||||||
Retry
|
{t.common.retry}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageLoader className="min-h-24" label="Loading memory provider settings..." />
|
return <PageLoader className="min-h-24" label={copy.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const inlineFields = config.fields.filter(field => field.inline)
|
const fields = config.fields.map(field => localizeMemoryField(config.name, field, locale))
|
||||||
const secretFields = config.fields.filter(field => field.kind === 'secret')
|
const inlineFields = fields.filter(field => field.inline)
|
||||||
|
const secretFields = fields.filter(field => field.kind === 'secret')
|
||||||
const hasFullConfig = config.fields.some(field => !field.inline)
|
const hasFullConfig = config.fields.some(field => !field.inline)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,16 +119,16 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
|||||||
>
|
>
|
||||||
<DisclosureCaret open={expanded} />
|
<DisclosureCaret open={expanded} />
|
||||||
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
||||||
{config.label} settings
|
{copy.settings(config.label)}
|
||||||
</span>
|
</span>
|
||||||
{secretFields.map(field => (
|
{secretFields.map(field => (
|
||||||
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
|
<Pill key={field.key}>{copy.credentialStatus(field.label, field.is_set)}</Pill>
|
||||||
))}
|
))}
|
||||||
</button>
|
</button>
|
||||||
{hasFullConfig && (
|
{hasFullConfig && (
|
||||||
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
|
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
|
||||||
<SlidersHorizontal className="size-3.5" />
|
<SlidersHorizontal className="size-3.5" />
|
||||||
Full config…
|
{copy.fullConfig}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router'
|
import { MemoryRouter } from 'react-router'
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
|
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
|
||||||
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
|
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
|
||||||
// them to let the dropdown open in tests.
|
// them to let the dropdown open in tests.
|
||||||
@@ -394,8 +396,8 @@ describe('ModelSettings', () => {
|
|||||||
fireEvent.click(applyButton)
|
fireEvent.click(applyButton)
|
||||||
|
|
||||||
// The switch-time notice names the pinned provider and offers a reset.
|
// The switch-time notice names the pinned provider and offers a reset.
|
||||||
expect(await screen.findByText(/still run on/)).toBeTruthy()
|
expect(await screen.findByText(/still run on nous/)).toBeTruthy()
|
||||||
expect(screen.getByText('nous')).toBeTruthy()
|
expect(screen.getAllByRole('button', { name: 'Reset all to main' }).length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a persistent banner when a loaded aux slot mismatches the main provider', async () => {
|
it('shows a persistent banner when a loaded aux slot mismatches the main provider', async () => {
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ interface StaleAuxWarningProps {
|
|||||||
// $0-balance provider after switching main away from it) and offers the
|
// $0-balance provider after switching main away from it) and offers the
|
||||||
// existing one-click reset rather than auto-clearing legitimate pins.
|
// existing one-click reset rather than auto-clearing legitimate pins.
|
||||||
function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) {
|
function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -169,11 +171,14 @@ function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarnin
|
|||||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||||
<AlertTriangle className="size-3.5 shrink-0" />
|
<AlertTriangle className="size-3.5 shrink-0" />
|
||||||
<span className="grow">
|
<span className="grow">
|
||||||
{slots.length} auxiliary task{slots.length === 1 ? '' : 's'} ({names}) still run on{' '}
|
{t.settings.runtime.auxiliaryWarning(
|
||||||
<span className="font-mono">{allSameProvider ? provider : 'other providers'}</span>, not your main model.
|
slots.length,
|
||||||
|
names,
|
||||||
|
allSameProvider ? provider : t.settings.runtime.otherProviders
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Button disabled={applying} onClick={onReset} size="sm" variant="textStrong">
|
<Button disabled={applying} onClick={onReset} size="sm" variant="textStrong">
|
||||||
Reset all to main
|
{t.settings.model.resetAllToMain}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -852,7 +857,9 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
void activateApiKeyProvider()
|
void activateApiKeyProvider()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={`Paste ${selectedProviderRow?.key_env ?? 'API key'}`}
|
placeholder={t.settings.runtime.pasteProviderKey(
|
||||||
|
selectedProviderRow?.key_env ?? t.settings.runtime.apiKey
|
||||||
|
)}
|
||||||
type="password"
|
type="password"
|
||||||
value={apiKeyDraft}
|
value={apiKeyDraft}
|
||||||
/>
|
/>
|
||||||
@@ -862,12 +869,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{activating && <Loader2 className="size-3.5 animate-spin" />}
|
{activating && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
{activating ? 'Activating...' : 'Activate'}
|
{activating ? t.settings.runtime.activatingProvider : t.settings.runtime.activateProvider}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
|
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
|
||||||
Set up {selectedProviderRow?.name ?? 'provider'}
|
{t.settings.runtime.setupProvider(selectedProviderRow?.name ?? t.settings.runtime.provider)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -1085,15 +1092,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
</section>
|
</section>
|
||||||
{moa && currentMoaPreset && (
|
{moa && currentMoaPreset && (
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading icon={Cpu} title="Mixture of Agents" />
|
<SectionHeading icon={Cpu} title={t.settings.runtime.moa.title} />
|
||||||
<p className="mb-2 text-xs text-muted-foreground">
|
<p className="mb-2 text-xs text-muted-foreground">{t.settings.runtime.moa.description}</p>
|
||||||
Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the
|
|
||||||
acting model.
|
|
||||||
</p>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
<Select onValueChange={setSelectedMoaPreset} value={selectedMoaPreset || moa.default_preset}>
|
<Select onValueChange={setSelectedMoaPreset} value={selectedMoaPreset || moa.default_preset}>
|
||||||
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
||||||
<SelectValue placeholder="Preset" />
|
<SelectValue placeholder={t.settings.runtime.moa.preset} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.keys(moa.presets).map(name => (
|
{Object.keys(moa.presets).map(name => (
|
||||||
@@ -1104,7 +1108,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<label className="flex items-center gap-2 rounded-sm border border-border px-2 py-1 text-xs">
|
<label className="flex items-center gap-2 rounded-sm border border-border px-2 py-1 text-xs">
|
||||||
Enabled
|
{t.settings.runtime.moa.enabled}
|
||||||
<Switch
|
<Switch
|
||||||
checked={currentMoaPreset.enabled !== false}
|
checked={currentMoaPreset.enabled !== false}
|
||||||
disabled={applying}
|
disabled={applying}
|
||||||
@@ -1125,7 +1129,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Set default
|
{t.settings.runtime.moa.setDefault}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={Object.keys(moa.presets).length <= 1 || applying}
|
disabled={Object.keys(moa.presets).length <= 1 || applying}
|
||||||
@@ -1151,12 +1155,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Delete
|
{t.common.delete}
|
||||||
</Button>
|
</Button>
|
||||||
<Input
|
<Input
|
||||||
className={cn('w-40', CONTROL_TEXT)}
|
className={cn('w-40', CONTROL_TEXT)}
|
||||||
onChange={event => setNewMoaPresetName(event.target.value)}
|
onChange={event => setNewMoaPresetName(event.target.value)}
|
||||||
placeholder="new preset"
|
placeholder={t.settings.runtime.moa.newPreset}
|
||||||
value={newMoaPresetName}
|
value={newMoaPresetName}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -1179,18 +1183,18 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="textStrong"
|
variant="textStrong"
|
||||||
>
|
>
|
||||||
Add preset
|
{t.settings.runtime.moa.addPreset}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 text-xs text-muted-foreground">
|
<div className="mb-2 text-xs text-muted-foreground">
|
||||||
Default: <span className="font-mono">{moa.default_preset}</span>
|
{t.settings.runtime.moa.default} <span className="font-mono">{moa.default_preset}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{currentMoaPreset.reference_models.map((slot, index) => (
|
{currentMoaPreset.reference_models.map((slot, index) => (
|
||||||
<ListRow
|
<ListRow
|
||||||
action={
|
action={
|
||||||
<Switch
|
<Switch
|
||||||
aria-label={`${slot.enabled !== false ? 'Disable' : 'Enable'} reference ${index + 1}`}
|
aria-label={t.settings.runtime.moa.toggleReference(index + 1, slot.enabled === false)}
|
||||||
checked={slot.enabled !== false}
|
checked={slot.enabled !== false}
|
||||||
disabled={applying}
|
disabled={applying}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
@@ -1267,7 +1271,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Remove
|
{t.common.remove}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1282,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
key={`${selectedMoaPreset}-${index}`}
|
key={`${selectedMoaPreset}-${index}`}
|
||||||
title={`Reference ${index + 1}`}
|
title={t.settings.runtime.moa.reference(index + 1)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
@@ -1292,7 +1296,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="textStrong"
|
variant="textStrong"
|
||||||
>
|
>
|
||||||
Add reference model
|
{t.settings.runtime.moa.addReference}
|
||||||
</Button>
|
</Button>
|
||||||
<ListRow
|
<ListRow
|
||||||
below={
|
below={
|
||||||
@@ -1354,7 +1358,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
{currentMoaPreset.aggregator.provider} · {currentMoaPreset.aggregator.model}
|
{currentMoaPreset.aggregator.provider} · {currentMoaPreset.aggregator.model}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
title="Aggregator"
|
title={t.settings.runtime.moa.aggregator}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
const { requestGateway, getProfiles } = vi.hoisted(() => ({
|
const { requestGateway, getProfiles } = vi.hoisted(() => ({
|
||||||
requestGateway: vi.fn(),
|
requestGateway: vi.fn(),
|
||||||
getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({
|
getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||||||
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
|
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
|
||||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||||
import { getProfiles } from '@/hermes'
|
import { getProfiles } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
|
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
|
||||||
import { normalize } from '@/lib/text'
|
import { normalize } from '@/lib/text'
|
||||||
@@ -48,6 +48,9 @@ function reveal(file: string) {
|
|||||||
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
|
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginText = (key: keyof Translations['settings']['runtime']['plugins']) =>
|
||||||
|
translateNow(`settings.runtime.plugins.${key}`)
|
||||||
|
|
||||||
async function revealPluginsDir() {
|
async function revealPluginsDir() {
|
||||||
try {
|
try {
|
||||||
// Electron owns the local plugin root — deriving it from the backend's
|
// Electron owns the local plugin root — deriving it from the backend's
|
||||||
@@ -55,7 +58,7 @@ async function revealPluginsDir() {
|
|||||||
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
|
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
|
||||||
|
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
|
notifyError(pluginText('unavailable'), pluginText('resolveFailed'))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,10 +68,10 @@ async function revealPluginsDir() {
|
|||||||
const result = await window.hermesDesktop?.openDir?.(dir)
|
const result = await window.hermesDesktop?.openDir?.(dir)
|
||||||
|
|
||||||
if (result && !result.ok) {
|
if (result && !result.ok) {
|
||||||
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
|
notifyError(result.error ?? pluginText('unknownError'), pluginText('openFailed'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Could not resolve the plugins folder')
|
notifyError(err, pluginText('resolveFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +85,7 @@ async function revealAgentPluginsDir(request: GatewayRequest) {
|
|||||||
const home = (result?.home ?? '').trim()
|
const home = (result?.home ?? '').trim()
|
||||||
|
|
||||||
if (!home) {
|
if (!home) {
|
||||||
notifyError('The backend did not report its home directory', 'Could not open the plugins folder')
|
notifyError(pluginText('noHome'), pluginText('openFailed'))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,10 +93,10 @@ async function revealAgentPluginsDir(request: GatewayRequest) {
|
|||||||
const opened = await window.hermesDesktop?.openDir?.(`${home}/plugins`)
|
const opened = await window.hermesDesktop?.openDir?.(`${home}/plugins`)
|
||||||
|
|
||||||
if (opened && !opened.ok) {
|
if (opened && !opened.ok) {
|
||||||
notifyError(opened.error ?? 'unknown error', 'Could not open the plugins folder')
|
notifyError(opened.error ?? pluginText('unknownError'), pluginText('openFailed'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Could not open the plugins folder')
|
notifyError(err, pluginText('openFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
|||||||
|
|
||||||
import { ListRow } from '@/app/settings/primitives'
|
import { ListRow } from '@/app/settings/primitives'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { $poolLimits, loadPoolLimits, savePoolLimits } from '@/store/pool-limits'
|
import { $poolLimits, loadPoolLimits, savePoolLimits } from '@/store/pool-limits'
|
||||||
|
|
||||||
// Bounds imported from main's clamp module so the advertised input ranges
|
// Bounds imported from main's clamp module so the advertised input ranges
|
||||||
@@ -16,6 +17,8 @@ const IDLE_MS_MAX = POOL_LIMITS_BOUNDS.idleMsMax
|
|||||||
* Device-local (not profile-scoped): the pool is sized once per machine and
|
* Device-local (not profile-scoped): the pool is sized once per machine and
|
||||||
* changes apply live — main evicts/reaps to converge without a restart. */
|
* changes apply live — main evicts/reaps to converge without a restart. */
|
||||||
export function PoolLimitsSetting() {
|
export function PoolLimitsSetting() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.runtime.pool
|
||||||
const limits = useStore($poolLimits)
|
const limits = useStore($poolLimits)
|
||||||
const [maxDraft, setMaxDraft] = useState(String(limits.maxBackends))
|
const [maxDraft, setMaxDraft] = useState(String(limits.maxBackends))
|
||||||
const [idleDraft, setIdleDraft] = useState(String(limits.idleMs))
|
const [idleDraft, setIdleDraft] = useState(String(limits.idleMs))
|
||||||
@@ -63,7 +66,7 @@ export function PoolLimitsSetting() {
|
|||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
aria-label="Warm bot backends"
|
aria-label={copy.warm}
|
||||||
className="w-20"
|
className="w-20"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
max={MAX_BACKENDS_MAX}
|
max={MAX_BACKENDS_MAX}
|
||||||
@@ -80,14 +83,14 @@ export function PoolLimitsSetting() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
description="How many bot backends stay running for instant switching. Higher = faster switches, more memory (~60MB per backend). Applies immediately."
|
description={copy.warmHint}
|
||||||
title="Warm Bot Backends"
|
title={copy.warm}
|
||||||
/>
|
/>
|
||||||
<ListRow
|
<ListRow
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
aria-label="Backend idle timeout in milliseconds"
|
aria-label={copy.idleAria}
|
||||||
className="w-28"
|
className="w-28"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
max={IDLE_MS_MAX}
|
max={IDLE_MS_MAX}
|
||||||
@@ -105,8 +108,8 @@ export function PoolLimitsSetting() {
|
|||||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">ms</span>
|
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">ms</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
description="How long an unused bot backend stays warm before it is shut down. Raise this so bots you revisit every few minutes never pay a cold start."
|
description={copy.idleHint}
|
||||||
title="Backend Idle Timeout"
|
title={copy.idle}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { atom } from 'nanostores'
|
import { atom } from 'nanostores'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ import { ConfirmHost } from '@/components/confirm-host'
|
|||||||
import { $confirmRequest } from '@/store/confirm'
|
import { $confirmRequest } from '@/store/confirm'
|
||||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
const listOAuthProviders = vi.fn()
|
const listOAuthProviders = vi.fn()
|
||||||
const disconnectOAuthProvider = vi.fn()
|
const disconnectOAuthProvider = vi.fn()
|
||||||
const getEnvVars = vi.fn()
|
const getEnvVars = vi.fn()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
|||||||
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
||||||
import { CustomEndpointsSettings } from './custom-endpoints-settings'
|
import { CustomEndpointsSettings } from './custom-endpoints-settings'
|
||||||
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
import { providerDescription, providerGroup, providerMeta, providerPriority } from './helpers'
|
||||||
import { LocalModelsSettings } from './local-models-settings'
|
import { LocalModelsSettings } from './local-models-settings'
|
||||||
import { SettingsContent, SettingsSkeleton } from './primitives'
|
import { SettingsContent, SettingsSkeleton } from './primitives'
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ export function ProvidersSettings({
|
|||||||
onViewChange,
|
onViewChange,
|
||||||
view
|
view
|
||||||
}: ProvidersSettingsProps) {
|
}: ProvidersSettingsProps) {
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const { rowProps, vars } = useEnvCredentials()
|
const { rowProps, vars } = useEnvCredentials()
|
||||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||||
@@ -466,9 +466,15 @@ export function ProvidersSettings({
|
|||||||
|
|
||||||
const visibleGroups = q
|
const visibleGroups = q
|
||||||
? keyGroups.filter(group => {
|
? keyGroups.filter(group => {
|
||||||
const haystack = [group.name, group.description ?? '', group.primary[0], ...group.advanced.map(([k]) => k)]
|
const haystack = [
|
||||||
|
group.name,
|
||||||
|
providerDescription(group.name, group.description, locale) ?? '',
|
||||||
|
group.description ?? '',
|
||||||
|
group.primary[0],
|
||||||
|
...group.advanced.map(([k]) => k)
|
||||||
|
]
|
||||||
|
|
||||||
return haystack.some(s => s.toLowerCase().includes(q))
|
return haystack.some(s => normalize(s).includes(q))
|
||||||
})
|
})
|
||||||
: keyGroups
|
: keyGroups
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, fireEvent, screen } from '@testing-library/react'
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { stubResizeObserver } from '@/test/jsdom'
|
import { stubResizeObserver } from '@/test/jsdom'
|
||||||
@@ -6,6 +6,7 @@ import type { ConfigFieldSchema } from '@/types/hermes'
|
|||||||
|
|
||||||
import { ConfigField } from './config-field'
|
import { ConfigField } from './config-field'
|
||||||
import { rankSearchOption, SearchableSelect } from './searchable-select'
|
import { rankSearchOption, SearchableSelect } from './searchable-select'
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
stubResizeObserver()
|
stubResizeObserver()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
import { controlVariants } from '@/components/ui/control'
|
import { controlVariants } from '@/components/ui/control'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +43,8 @@ export function SearchableSelect({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
placeholder = 'Search…',
|
placeholder,
|
||||||
emptyMessage = 'No results found.',
|
emptyMessage,
|
||||||
clearLabel
|
clearLabel
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
@@ -55,6 +56,8 @@ export function SearchableSelect({
|
|||||||
* Matches the existing <Select> pattern of EMPTY_SELECT_VALUE + "(none)". */
|
* Matches the existing <Select> pattern of EMPTY_SELECT_VALUE + "(none)". */
|
||||||
clearLabel?: string
|
clearLabel?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const searchPlaceholder = placeholder ?? t.settings.runtime.search
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ export function SearchableSelect({
|
|||||||
[onChange]
|
[onChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const displayValue = value !== '' && value !== undefined ? value : placeholder
|
const displayValue = value !== '' && value !== undefined ? value : searchPlaceholder
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover onOpenChange={setOpen} open={open}>
|
<Popover onOpenChange={setOpen} open={open}>
|
||||||
@@ -90,9 +93,9 @@ export function SearchableSelect({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
|
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
<Command filter={rankSearchOption}>
|
<Command filter={rankSearchOption}>
|
||||||
<CommandInput autoFocus placeholder={placeholder} />
|
<CommandInput autoFocus placeholder={searchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
<CommandEmpty>{emptyMessage ?? t.settings.runtime.noResults}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{clearLabel && (
|
{clearLabel && (
|
||||||
<CommandItem onSelect={() => handleSelect('')} value={clearLabel}>
|
<CommandItem onSelect={() => handleSelect('')} value={clearLabel}>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import type { TerminalBackendsResponse } from '@/types/hermes'
|
import type { TerminalBackendsResponse } from '@/types/hermes'
|
||||||
|
|
||||||
|
import { renderWithEnglish as render } from './test-locale'
|
||||||
|
|
||||||
const getTerminalBackends = vi.fn()
|
const getTerminalBackends = vi.fn()
|
||||||
const selectTerminalBackend = vi.fn()
|
const selectTerminalBackend = vi.fn()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { type RenderOptions, render as renderUi } from '@testing-library/react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n/context'
|
||||||
|
|
||||||
|
/** Component behavior fixtures use English explicitly; application-default
|
||||||
|
* and Turkish rendering are verified by the locale integration tests. */
|
||||||
|
export function renderWithEnglish(ui: ReactNode, options?: RenderOptions) {
|
||||||
|
return renderUi(ui, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
|
{children}
|
||||||
|
</I18nProvider>
|
||||||
|
),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { MemoryRouter } from 'react-router'
|
import { MemoryRouter } from 'react-router'
|
||||||
import type * as ReactRouterDom from 'react-router'
|
import type * as ReactRouterDom from 'react-router'
|
||||||
@@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
import type { ToolsetConfig } from '@/types/hermes'
|
import type { ToolsetConfig } from '@/types/hermes'
|
||||||
|
|
||||||
|
import { renderWithEnglish as rtlRender } from './test-locale'
|
||||||
|
|
||||||
// EnvVarField navigates to Settings → Keys via useNavigate, so every render
|
// EnvVarField navigates to Settings → Keys via useNavigate, so every render
|
||||||
// needs a router context. The navigate spy asserts the deep-link target.
|
// needs a router context. The navigate spy asserts the deep-link target.
|
||||||
const navigateSpy = vi.fn()
|
const navigateSpy = vi.fn()
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ describe('the catalog owns model curation', () => {
|
|||||||
renderMenu()
|
renderMenu()
|
||||||
await screen.findByText(/Gemini 2\.5 Flash/i)
|
await screen.findByText(/Gemini 2\.5 Flash/i)
|
||||||
|
|
||||||
const input = screen.getByRole('textbox', { name: 'Search models' })
|
const input = screen.getByRole('textbox', { name: /Search models|Model ara/ })
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: 'gemini-3.1' } })
|
fireEvent.change(input, { target: { value: 'gemini-3.1' } })
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ describe('the catalog owns model curation', () => {
|
|||||||
renderMenu()
|
renderMenu()
|
||||||
await screen.findByText(/Gemini 3\.1 Pro/i)
|
await screen.findByText(/Gemini 3\.1 Pro/i)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Edit models…'))
|
fireEvent.click(screen.getByText(/Edit models…|Modelleri düzenle…/))
|
||||||
|
|
||||||
expect($modelVisibilityOpen.get()).toBe(true)
|
expect($modelVisibilityOpen.get()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ export function ModelCatalogMenu({
|
|||||||
{shownMoaPresets.length > 0 ? (
|
{shownMoaPresets.length > 0 ? (
|
||||||
<div className={cn(quietRows)}>
|
<div className={cn(quietRows)}>
|
||||||
<DropdownMenuSeparator className="mx-0" />
|
<DropdownMenuSeparator className="mx-0" />
|
||||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
|
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{t.surfaces.moaPresets}</DropdownMenuLabel>
|
||||||
{shownMoaPresets.map(preset => {
|
{shownMoaPresets.map(preset => {
|
||||||
const isCurrentMoa = current.provider === 'moa' && current.model === preset
|
const isCurrentMoa = current.provider === 'moa' && current.model === preset
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
setSkillEnabled,
|
setSkillEnabled,
|
||||||
setToolsetEnabled
|
setToolsetEnabled
|
||||||
} from '@/hermes'
|
} from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { type Translations, useI18n } from '@/i18n'
|
||||||
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
|
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
|
||||||
import { compactNumber } from '@/lib/format'
|
import { compactNumber } from '@/lib/format'
|
||||||
import { Loader2 } from '@/lib/icons'
|
import { Loader2 } from '@/lib/icons'
|
||||||
@@ -118,7 +118,7 @@ const usageOf = (skill: SkillInfo): number => (typeof skill.usage === 'number' ?
|
|||||||
const categoryFor = (skill: SkillInfo): string => asText(skill.category) || 'general'
|
const categoryFor = (skill: SkillInfo): string => asText(skill.category) || 'general'
|
||||||
|
|
||||||
// Row subtitle: category, with non-default origins badged.
|
// Row subtitle: category, with non-default origins badged.
|
||||||
function skillSubtitle(skill: SkillInfo): React.ReactNode {
|
function skillSubtitle(skill: SkillInfo, copy: Translations['surfaces']): React.ReactNode {
|
||||||
const category = prettyName(categoryFor(skill))
|
const category = prettyName(categoryFor(skill))
|
||||||
const provenance = skill.provenance
|
const provenance = skill.provenance
|
||||||
|
|
||||||
@@ -127,12 +127,12 @@ function skillSubtitle(skill: SkillInfo): React.ReactNode {
|
|||||||
<span className="truncate">{category}</span>
|
<span className="truncate">{category}</span>
|
||||||
{provenance === 'agent' && (
|
{provenance === 'agent' && (
|
||||||
<Badge className="shrink-0 normal-case" variant="default">
|
<Badge className="shrink-0 normal-case" variant="default">
|
||||||
learned
|
{copy.learned}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{provenance === 'hub' && (
|
{provenance === 'hub' && (
|
||||||
<Badge className="shrink-0 normal-case" variant="muted">
|
<Badge className="shrink-0 normal-case" variant="muted">
|
||||||
hub
|
{copy.hub}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -927,7 +927,7 @@ export function SkillsView({
|
|||||||
setSelectedOfficial(null)
|
setSelectedOfficial(null)
|
||||||
}}
|
}}
|
||||||
onToggle={enabled => void handleToggleSkill(skill, enabled)}
|
onToggle={enabled => void handleToggleSkill(skill, enabled)}
|
||||||
subtitle={skillSubtitle(skill)}
|
subtitle={skillSubtitle(skill, t.surfaces)}
|
||||||
title={skill.name}
|
title={skill.name}
|
||||||
toggleLabel={skill.name}
|
toggleLabel={skill.name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1621,7 +1621,7 @@ function McpCatalog({
|
|||||||
</span>
|
</span>
|
||||||
<CatalogTag>{entry.transport}</CatalogTag>
|
<CatalogTag>{entry.transport}</CatalogTag>
|
||||||
{entry.auth_type === 'oauth' && <CatalogTag>OAuth</CatalogTag>}
|
{entry.auth_type === 'oauth' && <CatalogTag>OAuth</CatalogTag>}
|
||||||
{entry.auth_type === 'api_key' && <CatalogTag>API key</CatalogTag>}
|
{entry.auth_type === 'api_key' && <CatalogTag>{t.surfaces.apiKey}</CatalogTag>}
|
||||||
{entry.needs_install && !entry.installed && <CatalogTag>{m.catalogNeedsInstall}</CatalogTag>}
|
{entry.needs_install && !entry.installed && <CatalogTag>{m.catalogNeedsInstall}</CatalogTag>}
|
||||||
{entry.installed && (
|
{entry.installed && (
|
||||||
<span className="text-[0.6rem] text-emerald-400">
|
<span className="text-[0.6rem] text-emerald-400">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
|
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { evictStarmapNode, loadStarmapGraph } from '@/store/starmap'
|
import { evictStarmapNode, loadStarmapGraph } from '@/store/starmap'
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ interface EditState {
|
|||||||
|
|
||||||
/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */
|
/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */
|
||||||
export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) {
|
export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [editing, setEditing] = useState<EditState | null>(null)
|
const [editing, setEditing] = useState<EditState | null>(null)
|
||||||
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
|
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -125,7 +127,7 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
|||||||
onClick={() => void openEdit()}
|
onClick={() => void openEdit()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Edit {noun}…
|
{target.kind === 'skill' ? t.surfaces.editSkill : t.surfaces.editMemory}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs text-destructive hover:bg-destructive/10"
|
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs text-destructive hover:bg-destructive/10"
|
||||||
@@ -135,7 +137,7 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{target.kind === 'skill' ? 'Archive skill' : 'Delete memory'}
|
{target.kind === 'skill' ? t.surfaces.archiveSkill : t.surfaces.deleteMemory}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -144,7 +146,7 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
|||||||
<Dialog onOpenChange={value => !value && !saving && setEditing(null)} open={Boolean(editing)}>
|
<Dialog onOpenChange={value => !value && !saving && setEditing(null)} open={Boolean(editing)}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit {editing?.label}</DialogTitle>
|
<DialogTitle>{t.surfaces.editNode(editing?.label ?? '')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
{editing && (
|
{editing && (
|
||||||
@@ -162,10 +164,10 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
|||||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
|
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={saving} onClick={() => void save()}>
|
<Button disabled={saving} onClick={() => void save()}>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? t.common.saving : t.common.save}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -186,8 +188,8 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
confirmLabel="Delete"
|
confirmLabel={t.common.delete}
|
||||||
description="This memory is removed permanently."
|
description={t.surfaces.permanentMemory}
|
||||||
destructive
|
destructive
|
||||||
dismissOnConfirm
|
dismissOnConfirm
|
||||||
onClose={() => setDeleting(null)}
|
onClose={() => setDeleting(null)}
|
||||||
@@ -211,7 +213,7 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
open={Boolean(deleting)}
|
open={Boolean(deleting)}
|
||||||
title={`Delete ${deleting?.label ?? ''}?`}
|
title={t.surfaces.deleteNode(deleting?.label ?? '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { EN_SURFACES } from '@/i18n/en-surfaces'
|
||||||
|
import { TR_SURFACES } from '@/i18n/tr-surfaces'
|
||||||
import type { StarmapGraph } from '@/types/hermes'
|
import type { StarmapGraph } from '@/types/hermes'
|
||||||
|
|
||||||
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
|
import { decodeShareCode, encodeShareCode, ShareCodeError, shareCodeErrorText } from './share-code'
|
||||||
|
|
||||||
function sampleGraph(): StarmapGraph {
|
function sampleGraph(): StarmapGraph {
|
||||||
return {
|
return {
|
||||||
@@ -154,6 +156,36 @@ describe('share-code', () => {
|
|||||||
expect(() => decodeShareCode('')).toThrow(ShareCodeError)
|
expect(() => decodeShareCode('')).toThrow(ShareCodeError)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('formats real decoding failures in the selected language without changing valid codes', () => {
|
||||||
|
const graph = sampleGraph()
|
||||||
|
const valid = encodeShareCode(graph)
|
||||||
|
|
||||||
|
const capture = (code: string): unknown => {
|
||||||
|
try { decodeShareCode(code) } catch (error) { return error }
|
||||||
|
throw new Error('Expected a decoding failure')
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = capture('not a real code !!!')
|
||||||
|
expect(shareCodeErrorText(invalid, TR_SURFACES.mapErrors)).toBe('Bu metin bir harita koduna benzemiyor.')
|
||||||
|
expect(shareCodeErrorText(invalid, EN_SURFACES.mapErrors)).toBe("That doesn't look like a map code.")
|
||||||
|
|
||||||
|
const raw = atob(valid.slice(3).replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
const expected = raw.charCodeAt(0)
|
||||||
|
const actual = (expected + 1) % 256
|
||||||
|
|
||||||
|
const otherVersion = 'HML' + btoa(String.fromCharCode(actual) + raw.slice(1))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
|
||||||
|
expect(shareCodeErrorText(capture(otherVersion), TR_SURFACES.mapErrors))
|
||||||
|
.toBe(TR_SURFACES.mapErrors.version(actual, expected))
|
||||||
|
const middle = Math.floor(valid.length / 2)
|
||||||
|
const corrupted = valid.slice(0, middle) + (valid[middle] === 'A' ? 'B' : 'A') + valid.slice(middle + 1)
|
||||||
|
expect(shareCodeErrorText(capture(corrupted), TR_SURFACES.mapErrors)).toBe(TR_SURFACES.mapErrors.corrupted)
|
||||||
|
expect(shareCodeErrorText(new Error('internal details'), TR_SURFACES.mapErrors)).toBe('Harita kodu okunamadı.')
|
||||||
|
expect(encodeShareCode(graph)).toBe(valid)
|
||||||
|
expect(decodeShareCode(valid).nodes).toHaveLength(graph.nodes.length)
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects a corrupted (bit-flipped) code', () => {
|
it('rejects a corrupted (bit-flipped) code', () => {
|
||||||
const code = encodeShareCode(sampleGraph())
|
const code = encodeShareCode(sampleGraph())
|
||||||
// Flip a mid-payload char (trailing base64 bits can be dropped on decode).
|
// Flip a mid-payload char (trailing base64 bits can be dropped on decode).
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { EN_SURFACES } from '@/i18n/en-surfaces'
|
||||||
import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout'
|
import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout'
|
||||||
import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes'
|
import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes'
|
||||||
|
|
||||||
@@ -168,6 +169,13 @@ function readGraph(r: BitReader): StarmapGraph {
|
|||||||
|
|
||||||
export class ShareCodeError extends LoadoutError {}
|
export class ShareCodeError extends LoadoutError {}
|
||||||
|
|
||||||
|
export function shareCodeErrorText(error: unknown, messages: typeof EN_SURFACES.mapErrors): string {
|
||||||
|
if (!(error instanceof ShareCodeError) || !error.info) {return messages.unknown}
|
||||||
|
const info = error.info
|
||||||
|
|
||||||
|
return info.kind === 'version' ? messages.version(info.actual, info.expected) : messages[info.kind]
|
||||||
|
}
|
||||||
|
|
||||||
const codec = createLoadout<StarmapGraph>({
|
const codec = createLoadout<StarmapGraph>({
|
||||||
error: ShareCodeError,
|
error: ShareCodeError,
|
||||||
noun: 'map code',
|
noun: 'map code',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n/context'
|
||||||
|
import { TR_SURFACES } from '@/i18n/tr-surfaces'
|
||||||
|
|
||||||
|
import { decodeShareCode, shareCodeErrorText } from './share-code'
|
||||||
import { ShareControls } from './share-controls'
|
import { ShareControls } from './share-controls'
|
||||||
|
|
||||||
describe('ShareControls', () => {
|
describe('ShareControls', () => {
|
||||||
@@ -8,17 +12,35 @@ describe('ShareControls', () => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens its dialog when the trigger has a tooltip', async () => {
|
it.each([
|
||||||
render(<ShareControls shareCode="map-code" />)
|
['en', 'Import / export map'],
|
||||||
|
['tr', 'Haritayı içe / dışa aktar']
|
||||||
|
])('opens its dialog with a tooltip in %s', async (locale, title) => {
|
||||||
|
render(<I18nProvider configClient={null} initialLocale={locale}><ShareControls shareCode="map-code" /></I18nProvider>)
|
||||||
|
|
||||||
const trigger = screen.getByRole('button', { name: 'Import / export map' })
|
const trigger = screen.getByRole('button', { name: title })
|
||||||
|
|
||||||
fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
|
fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
|
||||||
expect((await screen.findByRole('tooltip')).textContent).toContain('Import / export map')
|
expect((await screen.findByRole('tooltip')).textContent).toContain(title)
|
||||||
|
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog')
|
const dialog = await screen.findByRole('dialog')
|
||||||
expect(within(dialog).getByRole('heading', { name: 'Import / export map' })).toBeTruthy()
|
expect(within(dialog).getByRole('heading', { name: title })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid pasted code editable and displays the Turkish decoder error', async () => {
|
||||||
|
render(<I18nProvider configClient={null} initialLocale="tr"><ShareControls onImport={code => {
|
||||||
|
try { decodeShareCode(code);
|
||||||
|
|
||||||
|
return null } catch (error) { return shareCodeErrorText(error, TR_SURFACES.mapErrors) }
|
||||||
|
}} /></I18nProvider>)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Haritayı içe / dışa aktar' }))
|
||||||
|
const dialog = await screen.findByRole('dialog')
|
||||||
|
const input = within(dialog).getByRole('textbox')
|
||||||
|
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true }))
|
||||||
|
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
|
||||||
|
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { atom, type WritableAtom } from 'nanostores'
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
|
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
|
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
|
||||||
import type { StarmapGraph } from '@/types/hermes'
|
import type { StarmapGraph } from '@/types/hermes'
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
|
|||||||
import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry'
|
import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry'
|
||||||
import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
|
import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
|
||||||
import { drawScene, drawScramble } from './render'
|
import { drawScene, drawScramble } from './render'
|
||||||
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
|
import { decodeShareCode, encodeShareCode, shareCodeErrorText } from './share-code'
|
||||||
import { ShareControls } from './share-controls'
|
import { ShareControls } from './share-controls'
|
||||||
import { buildSimulation } from './simulation'
|
import { buildSimulation } from './simulation'
|
||||||
import { formatDate } from './text'
|
import { formatDate } from './text'
|
||||||
@@ -106,6 +107,7 @@ export function StarMap({
|
|||||||
onImport?: (graph: StarmapGraph) => void
|
onImport?: (graph: StarmapGraph) => void
|
||||||
onResetMap?: () => void
|
onResetMap?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
const wrapRef = useRef<HTMLDivElement | null>(null)
|
const wrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@@ -196,10 +198,10 @@ export function StarMap({
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return err instanceof ShareCodeError ? err.message : 'Could not read that map code.'
|
return shareCodeErrorText(err, t.surfaces.mapErrors)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onImport]
|
[onImport, t.surfaces.mapErrors]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mark the canvas dirty and wake the (otherwise-idle) render loop.
|
// Mark the canvas dirty and wake the (otherwise-idle) render loop.
|
||||||
@@ -967,12 +969,13 @@ export function StarMap({
|
|||||||
{/* Legend — bottom-left, one entry per line like a conventional key. */}
|
{/* Legend — bottom-left, one entry per line like a conventional key. */}
|
||||||
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 text-[0.62rem] text-muted-foreground">
|
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 text-[0.62rem] text-muted-foreground">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="inline-block size-2 rounded-full bg-[var(--theme-primary)]/80" /> skill
|
<span className="inline-block size-2 rounded-full bg-[var(--theme-primary)]/80" /> {t.surfaces.skill}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="inline-block size-2 rotate-45" style={{ backgroundColor: memoryColor }} /> memory
|
<span className="inline-block size-2 rotate-45" style={{ backgroundColor: memoryColor }} />{' '}
|
||||||
|
{t.surfaces.memory}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[0.58rem] text-muted-foreground/65">core = oldest · outer = newer</span>
|
<span className="text-[0.58rem] text-muted-foreground/65">{t.surfaces.mapAge}</span>
|
||||||
<RevealLabel axis={timeAxis} revealStore={revealStore} />
|
<RevealLabel axis={timeAxis} revealStore={revealStore} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
import type { TimeAxis } from './time-axis'
|
import type { TimeAxis } from './time-axis'
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
revealStore,
|
revealStore,
|
||||||
ringStops = []
|
ringStops = []
|
||||||
}: TimelineProps) {
|
}: TimelineProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||||
const draggingRef = useRef(false)
|
const draggingRef = useRef(false)
|
||||||
const markerRefs = useRef<HTMLDivElement[]>([])
|
const markerRefs = useRef<HTMLDivElement[]>([])
|
||||||
@@ -185,7 +187,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
<style>{'@keyframes starmap-twinkle{0%,100%{opacity:var(--o,1)}50%{opacity:calc(var(--o,1) * 0.35)}}'}</style>
|
<style>{'@keyframes starmap-twinkle{0%,100%{opacity:var(--o,1)}50%{opacity:calc(var(--o,1) * 0.35)}}'}</style>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
aria-label={playing ? 'Pause' : 'Play timeline'}
|
aria-label={playing ? t.surfaces.pause : t.surfaces.playTimeline}
|
||||||
className="flex size-5 shrink-0 items-center justify-center text-foreground/75 transition-colors hover:text-foreground"
|
className="flex size-5 shrink-0 items-center justify-center text-foreground/75 transition-colors hover:text-foreground"
|
||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -194,7 +196,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
aria-label="Timeline scrubber"
|
aria-label={t.surfaces.timeline}
|
||||||
aria-valuemax={100}
|
aria-valuemax={100}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuenow={Math.round(revealStore.get() * 100)}
|
aria-valuenow={Math.round(revealStore.get() * 100)}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider, useI18n } from '@/i18n'
|
||||||
|
import { EN_SURFACES } from '@/i18n/en-surfaces'
|
||||||
|
import { TR_SURFACES } from '@/i18n/tr-surfaces'
|
||||||
|
import { $petActivity, $petState } from '@/store/pet'
|
||||||
|
|
||||||
|
import { PetBubble } from './pet-bubble'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
$petActivity.set({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes the visible status language while preserving the actual failure state', () => {
|
||||||
|
$petActivity.set({ error: true })
|
||||||
|
|
||||||
|
function Surface() {
|
||||||
|
const { setLocale } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => void setLocale('tr')}>Türkçe</button>
|
||||||
|
<PetBubble />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
|
<Surface />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(EN_SURFACES.pet.failed.some(line => view.container.textContent?.includes(line))).toBe(true)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Türkçe' }))
|
||||||
|
expect(TR_SURFACES.pet.failed.some(line => view.container.textContent?.includes(line))).toBe(true)
|
||||||
|
expect(EN_SURFACES.pet.failed.some(line => view.container.textContent?.includes(line))).toBe(false)
|
||||||
|
expect($petState.get()).toBe('failed')
|
||||||
|
expect($petActivity.get()).toEqual({ error: true })
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertCircle, Clock, type IconComponent } from '@/lib/icons'
|
import { AlertCircle, Clock, type IconComponent } from '@/lib/icons'
|
||||||
import { $petActivity, $petState, type PetState } from '@/store/pet'
|
import { $petActivity, $petState, type PetState } from '@/store/pet'
|
||||||
|
|
||||||
@@ -27,46 +28,6 @@ interface Spec {
|
|||||||
|
|
||||||
// Phrasings per mood, picked at random (no immediate repeat) for a bit of life.
|
// Phrasings per mood, picked at random (no immediate repeat) for a bit of life.
|
||||||
// Keep them short — the bubble is tiny and never wraps.
|
// Keep them short — the bubble is tiny and never wraps.
|
||||||
const SPECS: Partial<Record<PetState, Spec>> = {
|
|
||||||
run: {
|
|
||||||
lines: [
|
|
||||||
'working…',
|
|
||||||
'on it…',
|
|
||||||
'crunching…',
|
|
||||||
'tinkering…',
|
|
||||||
'cooking…',
|
|
||||||
'in the weeds…',
|
|
||||||
'wiring it up…',
|
|
||||||
'making moves…',
|
|
||||||
'heads down…',
|
|
||||||
'hammering away…'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
review: {
|
|
||||||
lines: [
|
|
||||||
'thinking…',
|
|
||||||
'reading…',
|
|
||||||
'reviewing…',
|
|
||||||
'pondering…',
|
|
||||||
'connecting dots…',
|
|
||||||
'sizing it up…',
|
|
||||||
'tracing it…',
|
|
||||||
'mulling…',
|
|
||||||
'scheming…',
|
|
||||||
'hmm…'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
glyph: AlertCircle,
|
|
||||||
lines: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'],
|
|
||||||
tone: 'error'
|
|
||||||
},
|
|
||||||
waiting: {
|
|
||||||
glyph: Clock,
|
|
||||||
lines: ['your turn', 'all yours', 'over to you', 'ball’s in your court', 'awaiting orders'],
|
|
||||||
tone: 'wait'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const TONE_COLOR: Record<Tone, string> = {
|
const TONE_COLOR: Record<Tone, string> = {
|
||||||
error: 'var(--ui-red)',
|
error: 'var(--ui-red)',
|
||||||
@@ -89,6 +50,18 @@ function pick(lines: string[], prev: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PetBubble() {
|
export function PetBubble() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const specs = useMemo<Partial<Record<PetState, Spec>>>(
|
||||||
|
() => ({
|
||||||
|
run: { lines: t.surfaces.pet.run },
|
||||||
|
review: { lines: t.surfaces.pet.review },
|
||||||
|
failed: { glyph: AlertCircle, lines: t.surfaces.pet.failed, tone: 'error' },
|
||||||
|
waiting: { glyph: Clock, lines: t.surfaces.pet.waiting, tone: 'wait' }
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
const state = useStore($petState)
|
const state = useStore($petState)
|
||||||
const activity = useStore($petActivity)
|
const activity = useStore($petActivity)
|
||||||
const [line, setLine] = useState('')
|
const [line, setLine] = useState('')
|
||||||
@@ -96,14 +69,14 @@ export function PetBubble() {
|
|||||||
// Finish beats are carried by the sprite/mail icon; idle only speaks up when
|
// Finish beats are carried by the sprite/mail icon; idle only speaks up when
|
||||||
// it's actually the user's turn. Everything else maps to a mood spec.
|
// it's actually the user's turn. Everything else maps to a mood spec.
|
||||||
const specKey: null | PetState =
|
const specKey: null | PetState =
|
||||||
state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null
|
state in specs ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null
|
||||||
|
|
||||||
const rotating = specKey === 'run' || specKey === 'review'
|
const rotating = specKey === 'run' || specKey === 'review'
|
||||||
|
|
||||||
// Pick a fresh line on every mood change, then keep rotating (random, no
|
// Pick a fresh line on every mood change, then keep rotating (random, no
|
||||||
// repeat) only while the agent is actively working/thinking.
|
// repeat) only while the agent is actively working/thinking.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const spec = specKey ? SPECS[specKey] : null
|
const spec = specKey ? specs[specKey] : null
|
||||||
|
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
setLine('')
|
setLine('')
|
||||||
@@ -120,9 +93,9 @@ export function PetBubble() {
|
|||||||
const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600)
|
const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600)
|
||||||
|
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [specKey, rotating])
|
}, [specKey, rotating, specs])
|
||||||
|
|
||||||
const spec = specKey ? SPECS[specKey] : null
|
const spec = specKey ? specs[specKey] : null
|
||||||
|
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
Vendored
+1
@@ -17,6 +17,7 @@ export {}
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
hermesDesktop: {
|
hermesDesktop: {
|
||||||
|
setUiLanguage?: (locale: string) => void
|
||||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||||
// profile's backend from the pool.
|
// profile's backend from the pool.
|
||||||
|
|||||||
@@ -30,15 +30,51 @@ describe('I18nProvider', () => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults to English without a config client', () => {
|
it('keeps the native menu aligned with loaded, optimistic and rolled-back language', async () => {
|
||||||
|
const original = window.hermesDesktop
|
||||||
|
const setUiLanguage = vi.fn()
|
||||||
|
window.hermesDesktop = { ...original, setUiLanguage }
|
||||||
|
let rejectSave: (error: Error) => void = () => undefined
|
||||||
|
|
||||||
|
const configClient: I18nConfigClient = {
|
||||||
|
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),
|
||||||
|
saveConfig: vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
rejectSave = reject
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<I18nProvider configClient={configClient}>
|
||||||
|
<LanguageProbe target="tr" />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(setUiLanguage).toHaveBeenLastCalledWith('en'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||||
|
await waitFor(() => expect(configClient.saveConfig).toHaveBeenCalledTimes(1))
|
||||||
|
expect(setUiLanguage).toHaveBeenLastCalledWith('tr')
|
||||||
|
rejectSave(new Error('Cannot save language'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('save-error').textContent).toBe('Cannot save language'))
|
||||||
|
expect(setUiLanguage).toHaveBeenLastCalledWith('en')
|
||||||
|
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||||
|
} finally {
|
||||||
|
cleanup()
|
||||||
|
window.hermesDesktop = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to Turkish without a config client', () => {
|
||||||
render(
|
render(
|
||||||
<I18nProvider configClient={null}>
|
<I18nProvider configClient={null}>
|
||||||
<LanguageProbe />
|
<LanguageProbe />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
expect(screen.getByTestId('locale').textContent).toBe('tr')
|
||||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
expect(screen.getByTestId('label').textContent).toBe('Dil')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes an initial locale alias and switches translations', async () => {
|
it('normalizes an initial locale alias and switches translations', async () => {
|
||||||
@@ -76,7 +112,7 @@ describe('I18nProvider', () => {
|
|||||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps English usable when config loading fails', async () => {
|
it('keeps Turkish usable when config loading fails', async () => {
|
||||||
const configClient: I18nConfigClient = {
|
const configClient: I18nConfigClient = {
|
||||||
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
||||||
saveConfig: vi.fn()
|
saveConfig: vi.fn()
|
||||||
@@ -90,8 +126,8 @@ describe('I18nProvider', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||||
|
|
||||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
expect(screen.getByTestId('locale').textContent).toBe('tr')
|
||||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
expect(screen.getByTestId('label').textContent).toBe('Dil')
|
||||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,8 +183,8 @@ describe('I18nProvider', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||||
|
|
||||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
expect(screen.getByTestId('locale').textContent).toBe('tr')
|
||||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
expect(screen.getByTestId('label').textContent).toBe('Dil')
|
||||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -217,14 +253,14 @@ describe('I18nProvider', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByTestId('locale').textContent).toBe('ar')
|
expect(screen.getByTestId('locale').textContent).toBe('ar')
|
||||||
expect(document.documentElement.dir).toBe('rtl')
|
expect(window.document.documentElement.dir).toBe('rtl')
|
||||||
expect(document.documentElement.lang).toBe('ar')
|
expect(window.document.documentElement.lang).toBe('ar')
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
|
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
|
||||||
expect(document.documentElement.dir).toBe('ltr')
|
expect(window.document.documentElement.dir).toBe('ltr')
|
||||||
expect(document.documentElement.lang).toBe('en')
|
expect(window.document.documentElement.lang).toBe('en')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rolls back the visible locale when saving fails', async () => {
|
it('rolls back the visible locale when saving fails', async () => {
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini
|
|||||||
localeRef.current = locale
|
localeRef.current = locale
|
||||||
setRuntimeI18nLocale(locale)
|
setRuntimeI18nLocale(locale)
|
||||||
applyDocumentLocale(locale)
|
applyDocumentLocale(locale)
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.hermesDesktop?.setUiLanguage?.(locale)
|
||||||
|
}
|
||||||
}, [locale])
|
}, [locale])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
export const EN_BILLING = {
|
||||||
|
retryAfter: (minutes: number) => ` (try again in ~${minutes} min)`,
|
||||||
|
rateLimited: (suffix: string) => `🟡 Too many charges right now${suffix}. This isn't a payment failure.`,
|
||||||
|
stripeRetry: (suffix: string) => `Stripe is having trouble — try again shortly${suffix}`,
|
||||||
|
reauthorize: (who: string) => `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
|
||||||
|
noSavedCardMessage:
|
||||||
|
"💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card).",
|
||||||
|
monthlyRemaining: (amount: string) => `🔴 Monthly spend cap reached — $${amount} headroom left.`,
|
||||||
|
chargeAdded: (amount: string) => `$${amount} added.`,
|
||||||
|
unconfirmed: (message: string) =>
|
||||||
|
`${message} Your last charge's outcome is unconfirmed - check your balance/history before retrying.`,
|
||||||
|
chargeFailed: (reason: string) => `The charge didn't go through (${reason}).`,
|
||||||
|
invalidAmount: (label: string) => `${label}: enter a dollar amount with at most 2 decimal places.`,
|
||||||
|
positiveAmount: (label: string) => `${label}: amount must be greater than $0.`,
|
||||||
|
minAmount: (label: string, amount: string) => `${label}: minimum is ${amount}.`,
|
||||||
|
maxAmount: (label: string, amount: string) => `${label}: maximum is ${amount}.`,
|
||||||
|
confirmCardMessage: 'Confirm this card for terminal charges in the portal',
|
||||||
|
confirmCardTitle: 'Card confirmation needed',
|
||||||
|
spendingApprovalMessage: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
|
||||||
|
spendingApprovalTitle: 'Remote Spending needs approval',
|
||||||
|
adminRevoked: 'An admin stopped remote spending for this terminal.',
|
||||||
|
userRevoked: 'You stopped remote spending for this terminal.',
|
||||||
|
spendingRevoked: 'Remote spending was stopped',
|
||||||
|
sessionRevokedMessage: 'Your session was logged out. Sign in again from Settings → Gateway.',
|
||||||
|
sessionRevokedTitle: 'Session logged out',
|
||||||
|
spendingDisabledMessage:
|
||||||
|
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
|
||||||
|
spendingDisabledTitle: 'Remote spending is off',
|
||||||
|
adminRequiredMessage: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
|
||||||
|
adminRequiredTitle: 'Admin role required',
|
||||||
|
conflictingChargeMessage: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
|
||||||
|
conflictingChargeTitle: 'Start a fresh top-up',
|
||||||
|
noSavedCard: 'No saved card',
|
||||||
|
orgDeniedMessage: "This token isn't bound to an org you can manage",
|
||||||
|
orgDeniedTitle: 'Org access denied',
|
||||||
|
monthlyReached: '🔴 Monthly spend cap reached.',
|
||||||
|
monthlyReachedTitle: 'Monthly spend cap reached',
|
||||||
|
rateLimitedTitle: 'Too many charges right now',
|
||||||
|
stripeTitle: 'Stripe is having trouble',
|
||||||
|
dailyLimitMessage: 'Daily plan-change limit reached — try again tomorrow',
|
||||||
|
dailyLimitTitle: 'Daily plan-change limit reached',
|
||||||
|
endpointMessage: 'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
|
||||||
|
endpointTitle: 'Billing endpoint unavailable',
|
||||||
|
requestTimeoutMessage: 'Billing request timed out.',
|
||||||
|
requestTimeoutTitle: 'Billing request timed out',
|
||||||
|
connectionFailedMessage: 'Billing request failed before reaching the gateway.',
|
||||||
|
connectionFailedTitle: 'Billing connection failed',
|
||||||
|
requestFailedMessage: 'Billing request failed.',
|
||||||
|
requestFailedTitle: 'Billing request failed',
|
||||||
|
creditsAdded: 'Credits added.',
|
||||||
|
chargeFailedTitle: 'Charge failed',
|
||||||
|
unconfirmedTitle: 'Charge outcome unconfirmed',
|
||||||
|
checkFailedMessage: 'Could not check the charge.',
|
||||||
|
checkFailedTitle: 'Could not check charge',
|
||||||
|
missingChargeId: 'The billing service accepted the request but did not return a charge id.',
|
||||||
|
untrackedTitle: 'Charge could not be tracked',
|
||||||
|
stillProcessingMessage: 'Charge may still settle. Check the portal before retrying.',
|
||||||
|
stillProcessingTitle: 'Still processing after 5 minutes',
|
||||||
|
bankVerification: 'Your bank requires verification (3DS). Complete it on the portal to finish this purchase.',
|
||||||
|
cardExpired: 'Your card has expired. Update it on the portal.',
|
||||||
|
cardDeclined: 'Your card was declined. Try another card on the portal.',
|
||||||
|
verificationDeniedMessage: 'Verification finished without allowing Remote Spending for this terminal.',
|
||||||
|
verificationDeniedTitle: 'Verification was not approved',
|
||||||
|
verificationDoneMessage: 'Remote Spending is allowed for this terminal.',
|
||||||
|
verificationDoneTitle: 'Verification complete',
|
||||||
|
reloadLabel: 'Reload-to',
|
||||||
|
reloadGreater: 'Reload-to amount must be greater than the threshold.',
|
||||||
|
formatDate: (date: Date) =>
|
||||||
|
new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' }).format(date),
|
||||||
|
monthSuffix: '/mo',
|
||||||
|
creditsMonthly: (amount: string) => `$${amount} credits/mo`,
|
||||||
|
alreadyPlan: (name: string) => `You are already on ${name} — nothing to change.`,
|
||||||
|
scheduledChange: (name: string, date: string, delta: string | null) =>
|
||||||
|
`Change to ${name} — takes effect ${date}. No charge now; you keep your current plan until then.${delta ? ` Monthly credits change: ${delta}.` : ''}`,
|
||||||
|
changesPlan: (name: string, date: string) => `Changes to ${name} on ${date}.`,
|
||||||
|
cancelsOn: (date: string) => `Cancels on ${date}.`,
|
||||||
|
renewsOn: (date: string) => `Renews ${date}`,
|
||||||
|
reconcileCard: (card: string) => `Auto-refill charges ${card} — reconcile on the portal`,
|
||||||
|
chargesBelow: (amount: string, threshold: string) =>
|
||||||
|
`Charges ${amount} automatically when your balance falls below ${threshold}.`,
|
||||||
|
remaining: (amount: string, total: string, over?: string) =>
|
||||||
|
`${amount} of ${total} left${over ? ` · ${over} over` : ''}`,
|
||||||
|
used: (amount: string, total: string) => `${amount} of ${total} used`,
|
||||||
|
resetsOn: (date: string) => `Resets ${date}`,
|
||||||
|
balanceAdded: (amount: string) => `${amount} added. Balance is refreshing.`,
|
||||||
|
usageLabel: (label: string) => `${label} usage`,
|
||||||
|
balance: 'Balance',
|
||||||
|
plan: 'Plan',
|
||||||
|
autoRefill: 'Auto-refill',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
off: 'Off',
|
||||||
|
portalArrow: 'Open portal ↗',
|
||||||
|
portal: 'Open portal',
|
||||||
|
connectMessage: 'Run /portal in the TUI or open the Nous portal to connect your account.',
|
||||||
|
connectTitle: 'Connect your Nous account',
|
||||||
|
addCard: 'Add card ↗',
|
||||||
|
noCardMessage: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
|
||||||
|
noCardTitle: 'No payment method on file',
|
||||||
|
free: 'Free',
|
||||||
|
subscriptionUnavailable: 'Subscription details are unavailable; opening the portal is still available.',
|
||||||
|
noSubscription: 'No active subscription — paid models draw down top-up credits.',
|
||||||
|
changePlan: 'Change plan',
|
||||||
|
viewPlans: 'View plans',
|
||||||
|
adjustPlan: 'Adjust plan ↗',
|
||||||
|
choose: 'Choose ↗',
|
||||||
|
addPayment: 'Add payment method',
|
||||||
|
paymentMethod: 'Payment method',
|
||||||
|
update: 'Update',
|
||||||
|
manageCard: 'Manage the card used for top-ups and subscription renewals.',
|
||||||
|
buy: 'Buy',
|
||||||
|
buyDescription: 'A single charge on your card, added to your balance today.',
|
||||||
|
buyCredits: 'Buy credits now',
|
||||||
|
refillDescription: 'Keep your balance topped up when it drops below your threshold.',
|
||||||
|
manage: 'Manage',
|
||||||
|
manageRefill: 'Manage auto-refill from the portal.',
|
||||||
|
refillWhenLow: 'Refill when low',
|
||||||
|
turnOnRefill: 'Turn on auto-refill from the portal',
|
||||||
|
anotherCard: 'a different card',
|
||||||
|
reconcile: 'Reconcile ↗',
|
||||||
|
subscriptionRemaining: 'Subscription credits remaining',
|
||||||
|
subscriptionCredits: 'Subscription credits',
|
||||||
|
noExpiry: 'Does not expire',
|
||||||
|
topupCredits: 'Top-up credits',
|
||||||
|
capUsed: 'Monthly spend cap used',
|
||||||
|
defaultCeiling: 'Default ceiling',
|
||||||
|
monthlyRemoteSpending: 'Monthly remote spending',
|
||||||
|
monthlyCap: 'Monthly spend cap',
|
||||||
|
refillCard: 'auto-refill card',
|
||||||
|
customerDefault: 'customer default',
|
||||||
|
subscriptionCard: 'subscription card',
|
||||||
|
customCredit: 'Custom credit amount',
|
||||||
|
processing: 'Processing… checking settlement',
|
||||||
|
retry: 'Retry',
|
||||||
|
billing: 'Billing',
|
||||||
|
paymentCredits: 'Payment & credits',
|
||||||
|
usage: 'Usage',
|
||||||
|
invoices: 'Invoices',
|
||||||
|
undoing: 'Undoing…',
|
||||||
|
undo: 'Undo',
|
||||||
|
checkingChange: 'Checking this change…',
|
||||||
|
cannotChange: 'That change cannot be made here.',
|
||||||
|
cannotSchedule: 'This change cannot be scheduled here.',
|
||||||
|
tryAgain: 'Try again',
|
||||||
|
scheduling: 'Scheduling…',
|
||||||
|
confirmDowngrade: 'Confirm downgrade',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
currentPlan: 'Current plan',
|
||||||
|
scheduled: 'Scheduled',
|
||||||
|
downgrade: 'Downgrade',
|
||||||
|
backBilling: 'Back to billing',
|
||||||
|
plans: 'Plans',
|
||||||
|
noPlans: 'No plans are available to change to right now.',
|
||||||
|
refillUpdated: 'Auto-refill updated.',
|
||||||
|
refillOff: 'Auto-refill turned off.',
|
||||||
|
threshold: 'Threshold',
|
||||||
|
reloadTo: 'Reload to',
|
||||||
|
refillThreshold: 'Auto-refill threshold',
|
||||||
|
refillAmount: 'Auto-refill reload-to amount',
|
||||||
|
turnOffQuestion: 'Turn off auto-refill?',
|
||||||
|
turnOff: 'Turn off',
|
||||||
|
disable: 'Disable',
|
||||||
|
saving: 'Saving…',
|
||||||
|
save: 'Save',
|
||||||
|
verificationPage: 'Open verification page',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
waitingVerification: 'Waiting for verification link…',
|
||||||
|
verifyContinue: 'Verify to continue'
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/** Settings copy formerly embedded directly in React components. */
|
||||||
|
export const EN_RUNTIME_SETTINGS = {
|
||||||
|
marketplaceThemes: 'From the VS Code Marketplace',
|
||||||
|
noMatchingThemes: (query: string) => `No installed themes match "${query}".`,
|
||||||
|
auxiliaryWarning: (count: number, tasks: string, provider: string) =>
|
||||||
|
`${count} auxiliary task${count === 1 ? '' : 's'} (${tasks}) still run on ${provider}, not your main model.`,
|
||||||
|
otherProviders: 'other providers',
|
||||||
|
pasteProviderKey: (key: string) => `Paste ${key}`,
|
||||||
|
apiKey: 'API key',
|
||||||
|
activatingProvider: 'Activating...',
|
||||||
|
activateProvider: 'Activate',
|
||||||
|
setupProvider: (provider: string) => `Set up ${provider}`,
|
||||||
|
provider: 'provider',
|
||||||
|
plugins: {
|
||||||
|
unavailable: 'Desktop plugins are unavailable',
|
||||||
|
resolveFailed: 'Could not resolve the plugins folder',
|
||||||
|
openFailed: 'Could not open the plugins folder',
|
||||||
|
unknownError: 'Unknown error',
|
||||||
|
noHome: 'The backend did not report its home directory'
|
||||||
|
},
|
||||||
|
moa: {
|
||||||
|
title: 'Mixture of Agents',
|
||||||
|
description:
|
||||||
|
'Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the acting model.',
|
||||||
|
preset: 'Preset',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
setDefault: 'Set default',
|
||||||
|
newPreset: 'new preset',
|
||||||
|
addPreset: 'Add preset',
|
||||||
|
default: 'Default:',
|
||||||
|
reference: (index: number) => `Reference ${index}`,
|
||||||
|
toggleReference: (index: number, enabled: boolean) => `${enabled ? 'Enable' : 'Disable'} reference ${index}`,
|
||||||
|
addReference: 'Add reference model',
|
||||||
|
aggregator: 'Aggregator'
|
||||||
|
},
|
||||||
|
computerUse: {
|
||||||
|
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
|
||||||
|
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker.',
|
||||||
|
granted: 'Granted',
|
||||||
|
notGranted: 'Not granted',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
statusFailed: 'Could not read Computer Use status',
|
||||||
|
permissionsFailed: 'Could not request permissions',
|
||||||
|
approveTitle: 'Approve in System Settings',
|
||||||
|
approveBody: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.',
|
||||||
|
checking: 'Checking Computer Use status…',
|
||||||
|
unsupported: (platform: string) => `Computer Use is not supported on this platform (${platform}).`,
|
||||||
|
install: 'Install the cua-driver backend below to drive this machine.',
|
||||||
|
installGrant: ' Then grant Accessibility and Screen Recording here.',
|
||||||
|
identity:
|
||||||
|
'Grants attach to CuaDriver’s own identity (com.trycua.driver), so the dialog names the process that drives your Mac.',
|
||||||
|
recheck: 'Recheck',
|
||||||
|
accessibility: 'Accessibility',
|
||||||
|
accessibilityHint: 'Lets cua-driver post clicks, keystrokes, and read the accessibility tree.',
|
||||||
|
screenRecording: 'Screen Recording',
|
||||||
|
screenRecordingHint: 'Lets cua-driver capture screenshots of app windows.',
|
||||||
|
driverHealth: 'Driver health',
|
||||||
|
ready: 'Ready',
|
||||||
|
notReady: 'Not ready',
|
||||||
|
readyHint: 'Computer Use is ready. Ask the agent to capture an app and click around.',
|
||||||
|
waiting: 'Waiting for approval…',
|
||||||
|
grant: 'Grant permissions'
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
warm: 'Warm Bot Backends',
|
||||||
|
warmHint:
|
||||||
|
'How many bot backends stay running for instant switching. Higher means faster switches and more memory (~60 MB per backend). Applies immediately.',
|
||||||
|
idle: 'Backend Idle Timeout',
|
||||||
|
idleAria: 'Backend idle timeout in milliseconds',
|
||||||
|
idleHint:
|
||||||
|
'How long an unused bot backend stays warm before it is shut down. Raise this so bots you revisit every few minutes avoid a cold start.'
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
saveChanges: 'Save changes',
|
||||||
|
startFailed: 'Could not start the connection.',
|
||||||
|
timeout: 'Timed out — try again.',
|
||||||
|
connectionFailed: 'Connection failed.',
|
||||||
|
connectOauth: 'Connect via OAuth',
|
||||||
|
reconnect: 'Reconnect',
|
||||||
|
connect: 'Connect',
|
||||||
|
apiKeySet: 'API key set',
|
||||||
|
oauthSet: 'OAuth set',
|
||||||
|
waiting: 'Waiting for browser consent…',
|
||||||
|
loadFailed: 'Memory provider settings failed to load',
|
||||||
|
loading: 'Loading memory provider settings…',
|
||||||
|
saveFailed: (label: string) => `Failed to save ${label}`,
|
||||||
|
settings: (label: string) => `${label} settings`,
|
||||||
|
credentialStatus: (label: string, set: boolean) => `${label} ${set ? 'set' : 'not set'}`,
|
||||||
|
fullConfig: 'Full config…',
|
||||||
|
saved: (label: string) => `${label} saved`,
|
||||||
|
updated: 'Memory provider configuration updated.',
|
||||||
|
fullTitle: (label: string) => `${label} — full configuration`,
|
||||||
|
fullDescription: (label: string, profile: string) =>
|
||||||
|
`Every ${label} option for the ${profile} profile. Blank fields fall back to the resolved host or built-in default.`,
|
||||||
|
reference: (label: string) => `${label} configuration reference`,
|
||||||
|
other: 'Other',
|
||||||
|
about: (label: string) => `About ${label}`,
|
||||||
|
keepSecret: 'Leave blank to keep current value',
|
||||||
|
set: 'set'
|
||||||
|
},
|
||||||
|
themeSearch: 'Search your themes or the VS Code Marketplace…',
|
||||||
|
showOptions: 'Show options',
|
||||||
|
search: 'Search…',
|
||||||
|
noResults: 'No results found.'
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/** Labels for secondary desktop surfaces and session filters. */
|
||||||
|
export const EN_SURFACES = {
|
||||||
|
pet: {
|
||||||
|
message: 'Message…',
|
||||||
|
openApp: 'Open in AITURK',
|
||||||
|
run: [
|
||||||
|
'working…',
|
||||||
|
'on it…',
|
||||||
|
'crunching…',
|
||||||
|
'tinkering…',
|
||||||
|
'cooking…',
|
||||||
|
'in the weeds…',
|
||||||
|
'wiring it up…',
|
||||||
|
'making moves…',
|
||||||
|
'heads down…',
|
||||||
|
'hammering away…'
|
||||||
|
],
|
||||||
|
review: [
|
||||||
|
'thinking…',
|
||||||
|
'reading…',
|
||||||
|
'reviewing…',
|
||||||
|
'pondering…',
|
||||||
|
'connecting dots…',
|
||||||
|
'sizing it up…',
|
||||||
|
'tracing it…',
|
||||||
|
'mulling…',
|
||||||
|
'scheming…',
|
||||||
|
'hmm…'
|
||||||
|
],
|
||||||
|
failed: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'],
|
||||||
|
waiting: ['your turn', 'all yours', 'over to you', 'ball’s in your court', 'awaiting orders']
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
updated: 'Updated',
|
||||||
|
project: 'Project',
|
||||||
|
status: 'Status',
|
||||||
|
profile: 'Profile',
|
||||||
|
created: 'Created',
|
||||||
|
tokens: 'Tokens',
|
||||||
|
cost: 'Cost',
|
||||||
|
manual: 'Manual',
|
||||||
|
preview: 'Preview',
|
||||||
|
pr: 'PR',
|
||||||
|
open: 'Open',
|
||||||
|
draft: 'Draft',
|
||||||
|
merged: 'Merged',
|
||||||
|
closed: 'Closed',
|
||||||
|
noPr: 'No PR',
|
||||||
|
needsInput: 'Needs input',
|
||||||
|
working: 'Working',
|
||||||
|
unread: 'Unread',
|
||||||
|
idle: 'Idle',
|
||||||
|
filters: 'Filters',
|
||||||
|
grouping: 'Grouping',
|
||||||
|
ordering: 'Ordering',
|
||||||
|
show: 'Show',
|
||||||
|
pullRequest: 'Pull request',
|
||||||
|
inbox: 'Inbox style',
|
||||||
|
archived: 'Archived',
|
||||||
|
reset: 'Reset to defaults',
|
||||||
|
expand: 'Expand all',
|
||||||
|
collapse: 'Collapse all',
|
||||||
|
markRead: 'Mark all as read'
|
||||||
|
},
|
||||||
|
quickEntry: {
|
||||||
|
title: 'Quick Entry',
|
||||||
|
ask: 'Ask AITURK…',
|
||||||
|
disconnected: 'Not connected — open AITURK to reconnect',
|
||||||
|
sendTo: 'Send to',
|
||||||
|
target: 'Target session',
|
||||||
|
current: 'Current chat',
|
||||||
|
newSession: 'New session'
|
||||||
|
},
|
||||||
|
imageBackendTitle: 'Add an image backend to generate',
|
||||||
|
imageBackendDescription: 'Hatching a custom pet needs a provider that can ground on a reference image.',
|
||||||
|
setupImage: 'Set up image generation',
|
||||||
|
keyFrom: 'Get a key from',
|
||||||
|
removeReference: 'Remove reference',
|
||||||
|
addReference: 'Add a reference',
|
||||||
|
sessionUnavailable: "Couldn't open this session",
|
||||||
|
noPage: (path: string) => `No page at ${path}`,
|
||||||
|
logUnavailable: (message: string) => `Log unavailable: ${message}`,
|
||||||
|
stop: 'Stop',
|
||||||
|
moaPresets: 'MoA presets',
|
||||||
|
learned: 'learned',
|
||||||
|
hub: 'hub',
|
||||||
|
apiKey: 'API key',
|
||||||
|
skill: 'skill',
|
||||||
|
memory: 'memory',
|
||||||
|
mapAge: 'core = oldest · outer = newer',
|
||||||
|
editSkill: 'Edit skill…',
|
||||||
|
editMemory: 'Edit memory…',
|
||||||
|
archiveSkill: 'Archive skill',
|
||||||
|
deleteMemory: 'Delete memory',
|
||||||
|
editNode: (name: string) => `Edit ${name}`,
|
||||||
|
deleteNode: (name: string) => `Delete ${name}?`,
|
||||||
|
permanentMemory: 'This memory is removed permanently.',
|
||||||
|
timeline: 'Timeline scrubber',
|
||||||
|
pause: 'Pause',
|
||||||
|
playTimeline: 'Play timeline',
|
||||||
|
mapErrors: {
|
||||||
|
invalid: "That doesn't look like a map code.",
|
||||||
|
short: 'Map code is too short to be valid.',
|
||||||
|
corrupted: 'Map code looks corrupted (checksum mismatch).',
|
||||||
|
malformed: 'Map code is malformed.',
|
||||||
|
version: (actual: number, expected: number) => `Map code is version ${actual}; this build reads version ${expected}.`,
|
||||||
|
unknown: 'Could not read that map code.'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||||
|
|
||||||
|
import { EN_RUNTIME_SETTINGS } from './en-runtime-settings'
|
||||||
|
import { EN_SURFACES } from './en-surfaces'
|
||||||
import type { Translations } from './types'
|
import type { Translations } from './types'
|
||||||
|
|
||||||
export const en: Translations = {
|
export const en: Translations = {
|
||||||
|
surfaces: EN_SURFACES,
|
||||||
common: {
|
common: {
|
||||||
apply: 'Apply',
|
apply: 'Apply',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
@@ -383,6 +386,7 @@ export const en: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
|
runtime: EN_RUNTIME_SETTINGS,
|
||||||
closeSettings: 'Close settings',
|
closeSettings: 'Close settings',
|
||||||
exportConfig: 'Export config',
|
exportConfig: 'Export config',
|
||||||
importConfig: 'Import config',
|
importConfig: 'Import config',
|
||||||
@@ -514,6 +518,7 @@ export const en: Translations = {
|
|||||||
completionSoundPreview: 'Preview'
|
completionSoundPreview: 'Preview'
|
||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
|
browser: 'Browser',
|
||||||
model: 'Model',
|
model: 'Model',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
appearance: 'Appearance',
|
appearance: 'Appearance',
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { EN_BILLING } from './en-billing'
|
||||||
|
|
||||||
|
export const TR_BILLING: typeof EN_BILLING = {
|
||||||
|
retryAfter: minutes => ` (yaklaşık ${minutes} dakika sonra yeniden deneyin)`,
|
||||||
|
rateLimited: suffix => `🟡 Şu anda çok fazla tahsilat isteği var${suffix}. Bu, ödeme başarısızlığı anlamına gelmez.`,
|
||||||
|
stripeRetry: suffix => `Stripe hizmetinde sorun var. Biraz sonra yeniden deneyin${suffix}`,
|
||||||
|
reauthorize: who => `${who} Bu cihaza yeniden yetki vermek için Ayarlar → Ağ geçitleri bölümünden tekrar bağlanın.`,
|
||||||
|
noSavedCardMessage:
|
||||||
|
'💳 Terminal tahsilatları için kayıtlı kart yok. Portal üzerinden kart ekleyin. Tek seferlik kredi satın alma, kartı sonraki işlemler için kaydetmez.',
|
||||||
|
monthlyRemaining: amount => `🔴 Aylık harcama sınırına ulaşıldı. Kalan harcama payı: $${amount}.`,
|
||||||
|
chargeAdded: amount => `$${amount} eklendi.`,
|
||||||
|
unconfirmed: message =>
|
||||||
|
`${message} Son tahsilatın sonucu henüz doğrulanmadı. Yeniden denemeden önce bakiyenizi ve işlem geçmişinizi kontrol edin.`,
|
||||||
|
chargeFailed: reason => `Tahsilat tamamlanamadı (${reason}).`,
|
||||||
|
invalidAmount: label => `${label}: en fazla iki ondalık basamaklı dolar tutarı girin.`,
|
||||||
|
positiveAmount: label => `${label}: tutar $0 değerinden büyük olmalıdır.`,
|
||||||
|
minAmount: (label, amount) => `${label}: en düşük tutar ${amount}.`,
|
||||||
|
maxAmount: (label, amount) => `${label}: en yüksek tutar ${amount}.`,
|
||||||
|
confirmCardMessage: 'Terminal tahsilatlarında kullanılacak bu kartı portal üzerinden onaylayın.',
|
||||||
|
confirmCardTitle: 'Kart onayı gerekiyor',
|
||||||
|
spendingApprovalMessage:
|
||||||
|
'Uzaktan harcama izni gerekiyor. İzin vermek için bir kredi yüklemesi başlatın ve yeniden deneyin.',
|
||||||
|
spendingApprovalTitle: 'Uzaktan harcama onayı gerekiyor',
|
||||||
|
adminRevoked: 'Bir yönetici bu terminalin uzaktan harcama iznini kaldırdı.',
|
||||||
|
userRevoked: 'Bu terminalin uzaktan harcama iznini kaldırdınız.',
|
||||||
|
spendingRevoked: 'Uzaktan harcama durduruldu',
|
||||||
|
sessionRevokedMessage: 'Oturumunuz kapatıldı. Ayarlar → Ağ geçitleri bölümünden yeniden giriş yapın.',
|
||||||
|
sessionRevokedTitle: 'Oturum kapatıldı',
|
||||||
|
spendingDisabledMessage:
|
||||||
|
'Bu hesapta uzaktan harcama kapalı. Bir faturalandırma yöneticisi, portalın Hermes Agent sayfasından etkinleştirebilir.',
|
||||||
|
spendingDisabledTitle: 'Uzaktan harcama kapalı',
|
||||||
|
adminRequiredMessage:
|
||||||
|
'Bakiye eklemek için kuruluş yöneticisi veya sahibi olmalısınız. Yöneticinize başvurun ya da portal üzerinden yönetin.',
|
||||||
|
adminRequiredTitle: 'Yönetici yetkisi gerekiyor',
|
||||||
|
conflictingChargeMessage:
|
||||||
|
'🔴 Bu tahsilat anahtarı başka bir tutar için kullanılmış. Yeni bir kredi yüklemesi başlatın.',
|
||||||
|
conflictingChargeTitle: 'Yeni bir kredi yüklemesi başlatın',
|
||||||
|
noSavedCard: 'Kayıtlı kart yok',
|
||||||
|
orgDeniedMessage: 'Bu erişim anahtarı, yönetebildiğiniz bir kuruluşa bağlı değil.',
|
||||||
|
orgDeniedTitle: 'Kuruluş erişimi reddedildi',
|
||||||
|
monthlyReached: '🔴 Aylık harcama sınırına ulaşıldı.',
|
||||||
|
monthlyReachedTitle: 'Aylık harcama sınırına ulaşıldı',
|
||||||
|
rateLimitedTitle: 'Şu anda çok fazla tahsilat isteği var',
|
||||||
|
stripeTitle: 'Stripe hizmetinde sorun var',
|
||||||
|
dailyLimitMessage: 'Günlük plan değiştirme sınırına ulaşıldı. Yarın yeniden deneyin.',
|
||||||
|
dailyLimitTitle: 'Günlük plan değiştirme sınırına ulaşıldı',
|
||||||
|
endpointMessage:
|
||||||
|
'Faturalandırma hizmeti beklenen JSON yanıtını döndürmedi. Bu yayında hizmet kullanılamıyor olabilir.',
|
||||||
|
endpointTitle: 'Faturalandırma hizmetine ulaşılamıyor',
|
||||||
|
requestTimeoutMessage: 'Faturalandırma isteği zaman aşımına uğradı.',
|
||||||
|
requestTimeoutTitle: 'Faturalandırma isteği zaman aşımına uğradı',
|
||||||
|
connectionFailedMessage: 'Faturalandırma isteği ağ geçidine ulaşmadan başarısız oldu.',
|
||||||
|
connectionFailedTitle: 'Faturalandırma bağlantısı kurulamadı',
|
||||||
|
requestFailedMessage: 'Faturalandırma isteği başarısız oldu.',
|
||||||
|
requestFailedTitle: 'Faturalandırma isteği başarısız oldu',
|
||||||
|
creditsAdded: 'Krediler eklendi.',
|
||||||
|
chargeFailedTitle: 'Tahsilat başarısız oldu',
|
||||||
|
unconfirmedTitle: 'Tahsilat sonucu doğrulanamadı',
|
||||||
|
checkFailedMessage: 'Tahsilat kontrol edilemedi.',
|
||||||
|
checkFailedTitle: 'Tahsilat kontrol edilemedi',
|
||||||
|
missingChargeId: 'Faturalandırma hizmeti isteği kabul etti ancak tahsilat kimliği döndürmedi.',
|
||||||
|
untrackedTitle: 'Tahsilat takip edilemedi',
|
||||||
|
stillProcessingMessage: 'Tahsilat hâlâ tamamlanabilir. Yeniden denemeden önce portalı kontrol edin.',
|
||||||
|
stillProcessingTitle: '5 dakika sonra hâlâ işleniyor',
|
||||||
|
bankVerification:
|
||||||
|
'Bankanız 3DS doğrulaması istiyor. Satın almayı bitirmek için portal üzerinden doğrulamayı tamamlayın.',
|
||||||
|
cardExpired: 'Kartınızın süresi dolmuş. Portal üzerinden güncelleyin.',
|
||||||
|
cardDeclined: 'Kartınız reddedildi. Portal üzerinden başka bir kart deneyin.',
|
||||||
|
verificationDeniedMessage: 'Doğrulama tamamlandı ancak bu terminal için uzaktan harcama izni verilmedi.',
|
||||||
|
verificationDeniedTitle: 'Doğrulama onaylanmadı',
|
||||||
|
verificationDoneMessage: 'Bu terminal için uzaktan harcama izni verildi.',
|
||||||
|
verificationDoneTitle: 'Doğrulama tamamlandı',
|
||||||
|
reloadLabel: 'Yüklenecek tutar',
|
||||||
|
reloadGreater: 'Yüklenecek tutar eşik değerinden büyük olmalıdır.',
|
||||||
|
formatDate: (date: Date) =>
|
||||||
|
new Intl.DateTimeFormat('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' }).format(date),
|
||||||
|
monthSuffix: '/ay',
|
||||||
|
creditsMonthly: amount => `Aylık $${amount} kredi`,
|
||||||
|
alreadyPlan: name => `Zaten ${name} planındasınız; değişiklik gerekmiyor.`,
|
||||||
|
scheduledChange: (name, date, delta) =>
|
||||||
|
`${name} planına geçiş ${date} tarihinde uygulanır. Şimdi tahsilat yapılmaz; o tarihe kadar mevcut planınız geçerlidir.${delta ? ` Aylık kredi değişimi: ${delta}.` : ''}`,
|
||||||
|
changesPlan: (name, date) => `${date} tarihinde ${name} planına geçilir.`,
|
||||||
|
cancelsOn: date => `${date} tarihinde iptal edilir.`,
|
||||||
|
renewsOn: date => `Yenileme: ${date}`,
|
||||||
|
reconcileCard: card => `Otomatik yüklemede ${card} kullanılıyor. Portal üzerinden kartı eşleştirin.`,
|
||||||
|
chargesBelow: (amount, threshold) =>
|
||||||
|
`Bakiyeniz ${threshold} altına düştüğünde otomatik olarak ${amount} tahsil edilir.`,
|
||||||
|
remaining: (amount, total, over) => `${total} tutarının ${amount} kadarı kaldı${over ? ` · ${over} aşım` : ''}`,
|
||||||
|
used: (amount, total) => `${total} tutarının ${amount} kadarı kullanıldı`,
|
||||||
|
resetsOn: date => `Sıfırlanma: ${date}`,
|
||||||
|
balanceAdded: amount => `${amount} eklendi. Bakiye yenileniyor.`,
|
||||||
|
usageLabel: label => `${label} kullanımı`,
|
||||||
|
balance: 'Bakiye',
|
||||||
|
plan: 'Plan',
|
||||||
|
autoRefill: 'Otomatik yükleme',
|
||||||
|
enabled: 'Etkin',
|
||||||
|
off: 'Kapalı',
|
||||||
|
portalArrow: 'Portalı aç ↗',
|
||||||
|
portal: 'Portalı aç',
|
||||||
|
connectMessage: 'Hesabınızı bağlamak için terminal arayüzünde /portal komutunu çalıştırın veya Nous portalını açın.',
|
||||||
|
connectTitle: 'Nous hesabınızı bağlayın',
|
||||||
|
addCard: 'Kart ekle ↗',
|
||||||
|
noCardMessage: 'Kredi satın alma ve otomatik yükleme için kayıtlı kart gerekir. Portal üzerinden kart ekleyin.',
|
||||||
|
noCardTitle: 'Kayıtlı ödeme yöntemi yok',
|
||||||
|
free: 'Ücretsiz',
|
||||||
|
subscriptionUnavailable: 'Abonelik ayrıntılarına ulaşılamıyor. Portal üzerinden kontrol edebilirsiniz.',
|
||||||
|
noSubscription: 'Etkin abonelik yok. Ücretli modeller yüklediğiniz kredileri kullanır.',
|
||||||
|
changePlan: 'Planı değiştir',
|
||||||
|
viewPlans: 'Planları görüntüle',
|
||||||
|
adjustPlan: 'Planı düzenle ↗',
|
||||||
|
choose: 'Seç ↗',
|
||||||
|
addPayment: 'Ödeme yöntemi ekle',
|
||||||
|
paymentMethod: 'Ödeme yöntemi',
|
||||||
|
update: 'Güncelle',
|
||||||
|
manageCard: 'Kredi yüklemeleri ve abonelik yenilemeleri için kullanılan kartı yönetin.',
|
||||||
|
buy: 'Satın al',
|
||||||
|
buyDescription: 'Kartınızdan tek seferlik tahsilat yapılır ve tutar bugün bakiyenize eklenir.',
|
||||||
|
buyCredits: 'Şimdi kredi satın al',
|
||||||
|
refillDescription: 'Bakiyeniz belirlediğiniz eşiğin altına düştüğünde otomatik kredi yükleyin.',
|
||||||
|
manage: 'Yönet',
|
||||||
|
manageRefill: 'Otomatik yüklemeyi portal üzerinden yönetin.',
|
||||||
|
refillWhenLow: 'Bakiye azaldığında yükle',
|
||||||
|
turnOnRefill: 'Otomatik yüklemeyi portal üzerinden etkinleştirin',
|
||||||
|
anotherCard: 'başka bir kart',
|
||||||
|
reconcile: 'Kartı eşleştir ↗',
|
||||||
|
subscriptionRemaining: 'Kalan abonelik kredileri',
|
||||||
|
subscriptionCredits: 'Abonelik kredileri',
|
||||||
|
noExpiry: 'Süresi dolmaz',
|
||||||
|
topupCredits: 'Yüklenen krediler',
|
||||||
|
capUsed: 'Kullanılan aylık harcama sınırı',
|
||||||
|
defaultCeiling: 'Varsayılan üst sınır',
|
||||||
|
monthlyRemoteSpending: 'Aylık uzaktan harcama',
|
||||||
|
monthlyCap: 'Aylık harcama sınırı',
|
||||||
|
refillCard: 'otomatik yükleme kartı',
|
||||||
|
customerDefault: 'müşteri varsayılanı',
|
||||||
|
subscriptionCard: 'abonelik kartı',
|
||||||
|
customCredit: 'Özel kredi tutarı',
|
||||||
|
processing: 'İşleniyor… tahsilat kontrol ediliyor',
|
||||||
|
retry: 'Yeniden dene',
|
||||||
|
billing: 'Faturalandırma',
|
||||||
|
paymentCredits: 'Ödeme ve krediler',
|
||||||
|
usage: 'Kullanım',
|
||||||
|
invoices: 'Faturalar',
|
||||||
|
undoing: 'Geri alınıyor…',
|
||||||
|
undo: 'Geri al',
|
||||||
|
checkingChange: 'Değişiklik kontrol ediliyor…',
|
||||||
|
cannotChange: 'Bu değişiklik buradan yapılamaz.',
|
||||||
|
cannotSchedule: 'Bu değişiklik buradan zamanlanamaz.',
|
||||||
|
tryAgain: 'Yeniden dene',
|
||||||
|
scheduling: 'Zamanlanıyor…',
|
||||||
|
confirmDowngrade: 'Alt plana geçişi onayla',
|
||||||
|
cancel: 'İptal',
|
||||||
|
currentPlan: 'Mevcut plan',
|
||||||
|
scheduled: 'Zamanlandı',
|
||||||
|
downgrade: 'Alt plana geç',
|
||||||
|
backBilling: 'Faturalandırmaya dön',
|
||||||
|
plans: 'Planlar',
|
||||||
|
noPlans: 'Şu anda geçiş yapılabilecek bir plan yok.',
|
||||||
|
refillUpdated: 'Otomatik yükleme güncellendi.',
|
||||||
|
refillOff: 'Otomatik yükleme kapatıldı.',
|
||||||
|
threshold: 'Eşik',
|
||||||
|
reloadTo: 'Yüklenecek tutar',
|
||||||
|
refillThreshold: 'Otomatik yükleme eşiği',
|
||||||
|
refillAmount: 'Otomatik yükleme tutarı',
|
||||||
|
turnOffQuestion: 'Otomatik yükleme kapatılsın mı?',
|
||||||
|
turnOff: 'Kapat',
|
||||||
|
disable: 'Devre dışı bırak',
|
||||||
|
saving: 'Kaydediliyor…',
|
||||||
|
save: 'Kaydet',
|
||||||
|
verificationPage: 'Doğrulama sayfasını aç',
|
||||||
|
dismiss: 'Kapat',
|
||||||
|
waitingVerification: 'Doğrulama bağlantısı bekleniyor…',
|
||||||
|
verifyContinue: 'Devam etmek için doğrulayın'
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user