Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75afd01b4b | ||
|
|
7724d65e92 | ||
|
|
5e76f58637 |
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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) {
|
||||||
@@ -6086,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: ['*'] }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -6755,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6886,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) {
|
||||||
@@ -7176,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
|
||||||
})
|
})
|
||||||
@@ -7535,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
|
||||||
@@ -7964,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) {
|
||||||
@@ -8121,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) {
|
||||||
@@ -8413,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,
|
||||||
@@ -8518,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,
|
||||||
@@ -16764,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
|
||||||
@@ -16787,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
|
||||||
})
|
})
|
||||||
@@ -16955,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)) {
|
||||||
@@ -17287,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')
|
||||||
})
|
})
|
||||||
@@ -17847,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()) {
|
||||||
@@ -17859,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.4",
|
"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()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type { Translations } from './types'
|
||||||
|
|
||||||
|
export const TR_SKILLS: Translations['skills'] = {
|
||||||
|
tabSkills: 'Beceriler',
|
||||||
|
tabToolsets: 'Araçlar',
|
||||||
|
configuringProfile: 'Yapılandırılan profil:',
|
||||||
|
tabMcp: 'MCP',
|
||||||
|
all: 'Tümü',
|
||||||
|
searchSkills: 'Becerilerde ara…',
|
||||||
|
searchToolsets: 'Araçlarda ara…',
|
||||||
|
refresh: 'Becerileri yenile',
|
||||||
|
refreshing: 'Beceriler yenileniyor',
|
||||||
|
loading: 'Yetenekler yükleniyor…',
|
||||||
|
noSkillsTitle: 'Beceri bulunamadı',
|
||||||
|
noSkillsDesc: 'Daha genel bir arama veya başka bir kategori deneyin.',
|
||||||
|
noToolsetsTitle: 'Araç grubu bulunamadı',
|
||||||
|
noToolsetsDesc: 'Daha genel bir arama deneyin.',
|
||||||
|
noDescription: 'Açıklama yok.',
|
||||||
|
configured: 'Yapılandırıldı',
|
||||||
|
needsKeys: 'Anahtar gerekiyor',
|
||||||
|
visionModelHint:
|
||||||
|
'Görsel anlama, yardımcı model ayarlarınızı kullanır. Görsel destekleyen modeli sağlayıcı bazında burada değil, model ayarlarında seçin.',
|
||||||
|
visionModelLink: 'Ayarlar → Modeller bölümünde görsel modelini seçin',
|
||||||
|
toolsetsEnabled: (enabled, total) => `${total} araç grubundan ${enabled} tanesi etkin`,
|
||||||
|
configureToolset: label => `${label} ayarları`,
|
||||||
|
toggleToolset: (label, enabled) => `${label} araç grubunu ${enabled ? 'etkinleştir' : 'devre dışı bırak'}`,
|
||||||
|
skillsLoadFailed: 'Beceriler yüklenemedi',
|
||||||
|
toolsetsRefreshFailed: 'Araç grupları yenilenemedi',
|
||||||
|
skillEnabled: 'Beceri etkinleştirildi',
|
||||||
|
skillDisabled: 'Beceri devre dışı bırakıldı',
|
||||||
|
toolsetEnabled: 'Araç grubu etkinleştirildi',
|
||||||
|
toolsetDisabled: 'Araç grubu devre dışı bırakıldı',
|
||||||
|
appliesToNewSessions: name => `${name} yeni oturumlarda geçerli olur.`,
|
||||||
|
failedToUpdate: name => `${name} güncellenemedi`,
|
||||||
|
sortMostUsed: 'En çok kullanılan',
|
||||||
|
sortAlpha: 'A–Z',
|
||||||
|
sortMostUsedDesc: '↓ En çok kullanılan',
|
||||||
|
sortLeastUsedAsc: '↑ En az kullanılan',
|
||||||
|
enableAll: 'Tümünü etkinleştir',
|
||||||
|
disableAll: 'Tümünü devre dışı bırak',
|
||||||
|
disableUnused: 'Kullanılmayanları devre dışı bırak',
|
||||||
|
bulkUpdated: count => `Yeni oturumlar için ${count} öğe güncellendi.`,
|
||||||
|
bulkNoChange: 'Değiştirilecek öğe yok.',
|
||||||
|
usageCount: count => `${count} kez kullanıldı`,
|
||||||
|
provenance: { agent: 'Öğrenilen', bundled: 'Yerleşik', hub: 'Beceri merkezi' },
|
||||||
|
emptyNoneFound: noun => `${noun} bulunamadı`,
|
||||||
|
emptyNothingMatches: query => `“${query}” ile eşleşen sonuç yok.`,
|
||||||
|
emptyNoneAvailable: noun => `Henüz kullanılabilir ${noun} yok.`,
|
||||||
|
changesApplyNewSessions: 'Değişiklikler yeni oturumlarda geçerli olur.',
|
||||||
|
skillUpdated: 'Beceri güncellendi',
|
||||||
|
edit: 'Düzenle',
|
||||||
|
archive: 'Arşivle',
|
||||||
|
skillArchivedTitle: 'Beceri arşivlendi',
|
||||||
|
skillArchivedMessage: 'hermes curator restore komutuyla geri yüklenebilir.',
|
||||||
|
officialCatalog: 'Kurulabilecek beceriler',
|
||||||
|
officialPill: 'Resmî',
|
||||||
|
hub: {
|
||||||
|
searchPlaceholder: 'Beceri merkezinde ara',
|
||||||
|
search: 'Ara',
|
||||||
|
searching: 'Aranıyor…',
|
||||||
|
connectingHubs: 'Beceri merkezlerine bağlanılıyor…',
|
||||||
|
connectedHubs: 'Bağlı merkezler:',
|
||||||
|
featured: 'Öne çıkan beceriler',
|
||||||
|
landingHint:
|
||||||
|
'Resmî dizin, GitHub ve topluluk kaynaklarındaki kurulabilir becerileri bulmak için merkezde arama yapın.',
|
||||||
|
noResults: 'Merkezde eşleşen beceri bulunamadı.',
|
||||||
|
resultCount: (count, ms) => `${count} sonuç${ms !== null ? ` · ${ms} ms` : ''}`,
|
||||||
|
timedOut: sources => `Zaman aşımına uğrayan kaynaklar: ${sources}`,
|
||||||
|
installed: 'Kurulu',
|
||||||
|
install: 'Kur',
|
||||||
|
installing: 'Kuruluyor…',
|
||||||
|
uninstall: 'Kaldır',
|
||||||
|
uninstalling: 'Kaldırılıyor…',
|
||||||
|
updateAll: 'Kurulu becerileri güncelle',
|
||||||
|
updating: 'Güncelleniyor…',
|
||||||
|
preview: 'Önizle',
|
||||||
|
scan: 'Tara',
|
||||||
|
scanning: 'Taranıyor…',
|
||||||
|
close: 'Kapat',
|
||||||
|
files: 'Dosyalar',
|
||||||
|
noReadme: 'Bu becerinin SKILL.md önizlemesi yok.',
|
||||||
|
trust: { builtin: 'yerleşik', trusted: 'güvenilir', community: 'topluluk' },
|
||||||
|
verdictSafe: 'Güvenli',
|
||||||
|
verdictCaution: 'Dikkat gerektiriyor',
|
||||||
|
verdictDangerous: 'Tehlikeli',
|
||||||
|
policyAllow: 'Kuruluma izin veriliyor',
|
||||||
|
policyAsk: 'Kurmadan önce inceleyin',
|
||||||
|
policyBlock: 'Kurulum ilke gereği engellendi',
|
||||||
|
findings: count => `${count} bulgu`,
|
||||||
|
noFindings: 'Güvenlik bulgusu yok.',
|
||||||
|
installStarted: name => `${name} kuruluyor…`,
|
||||||
|
uninstallStarted: name => `${name} kaldırılıyor…`,
|
||||||
|
updateStarted: 'Kurulu beceriler güncelleniyor…',
|
||||||
|
actionFailed: 'Beceri işlemi başarısız oldu',
|
||||||
|
actionLog: 'İşlem günlüğü',
|
||||||
|
alreadyInstalled: name => `“${name}” zaten kurulu`,
|
||||||
|
pickerTitle: 'Beceri merkezi',
|
||||||
|
pickerBrowse: 'Tüm merkeze göz at',
|
||||||
|
pickerHide: 'Merkez tarayıcısını gizle',
|
||||||
|
pickerHint: 'Bir beceride “+ Bu ajana ekle” düğmesine basın; beceri kurulur ve yukarıdaki listede görünür.',
|
||||||
|
loadFailed: 'Beceri merkezi yüklenemedi',
|
||||||
|
previewFailed: 'Beceri önizlemesi açılamadı',
|
||||||
|
scanFailed: 'Güvenlik taraması başarısız oldu',
|
||||||
|
searchFailed: 'Merkez araması başarısız oldu'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TR_STARMAP: Translations['starmap'] = {
|
||||||
|
title: 'Bellek haritası',
|
||||||
|
subtitle: (nodes, clusters) => `${clusters} kategoride ${nodes} beceri`,
|
||||||
|
close: 'Bellek haritasını kapat',
|
||||||
|
refresh: 'Yenile',
|
||||||
|
memory: 'Bellek',
|
||||||
|
filterAll: 'Tümü',
|
||||||
|
filterUsed: 'Kullanılan',
|
||||||
|
filterLearned: 'Öğrenilen',
|
||||||
|
viewGraph: 'Harita',
|
||||||
|
loadFailed: 'Bellek haritası yüklenemedi',
|
||||||
|
loading: 'Yükleniyor…',
|
||||||
|
emptyTitle: 'Henüz öğrenilen bilgi yok',
|
||||||
|
emptyDesc: 'AITURK çalışmalarınız için beceriler ve anılar geliştirdikçe burada görünürler.',
|
||||||
|
share: 'Haritayı paylaş',
|
||||||
|
shareHint:
|
||||||
|
'Haritayı paylaşmak için kodu kopyalayın veya bir harita yüklemek için kod yapıştırın. Kod yalnızca yerleşimi içerir; bellek ve beceri metinlerinizi içermez.',
|
||||||
|
shareTitle: 'Haritayı içe / dışa aktar',
|
||||||
|
sharePlaceholder: 'Harita kodunu yapıştırın…',
|
||||||
|
copy: 'Harita kodunu kopyala',
|
||||||
|
copied: 'Kopyalandı!',
|
||||||
|
importMap: 'Harita içe aktar',
|
||||||
|
importBtn: 'Yükle',
|
||||||
|
importEmpty: 'Yüklemek için bir harita kodu yapıştırın.',
|
||||||
|
importSuccess: nodes => `${nodes} düğümlü harita yüklendi.`,
|
||||||
|
importedBadge: 'içe aktarılan harita',
|
||||||
|
resetToMine: 'Kendi haritama dön'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TR_AGENTS: Translations['agents'] = {
|
||||||
|
close: 'Ajanları kapat',
|
||||||
|
title: 'Alt ajan ağacı',
|
||||||
|
subtitle: 'Geçerli turdaki alt ajanların canlı etkinliği.',
|
||||||
|
emptyTitle: 'Çalışan alt ajan yok',
|
||||||
|
emptyDesc: 'Bir turda iş başka ajanlara devredildiğinde alt ajanların ilerlemesi burada görünür.',
|
||||||
|
running: 'Çalışıyor',
|
||||||
|
failed: 'Başarısız',
|
||||||
|
done: 'Tamamlandı',
|
||||||
|
streaming: 'Yanıt aktarılıyor',
|
||||||
|
files: 'Dosyalar',
|
||||||
|
moreFiles: count => `+${count} dosya daha`,
|
||||||
|
delegation: index => `Görev devri ${index}`,
|
||||||
|
workers: count => `${count} çalışan`,
|
||||||
|
workersActive: count => `${count} etkin`,
|
||||||
|
agentsCount: count => `${count} ajan`,
|
||||||
|
activeCount: count => `${count} etkin`,
|
||||||
|
failedCount: count => `${count} başarısız`,
|
||||||
|
toolsCount: count => `${count} araç`,
|
||||||
|
filesCount: count => `${count} dosya`,
|
||||||
|
updatedAgo: age => `Güncellendi: ${age}`,
|
||||||
|
ageNow: 'şimdi',
|
||||||
|
ageSeconds: seconds => `${seconds} sn önce`,
|
||||||
|
ageMinutes: minutes => `${minutes} dk önce`,
|
||||||
|
ageHours: hours => `${hours} sa önce`,
|
||||||
|
ageDays: days => `${days} gün önce`,
|
||||||
|
durationSeconds: seconds => `${seconds} sn`,
|
||||||
|
durationMinutes: (minutes, seconds) => `${minutes} dk ${seconds} sn`,
|
||||||
|
tokens: value => `${value} belirteç`
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type { Translations } from './types'
|
||||||
|
|
||||||
|
export const TR_COMMAND_CENTER: Translations['commandCenter'] = {
|
||||||
|
close: 'Komut merkezini kapat',
|
||||||
|
paletteTitle: 'Komut paleti',
|
||||||
|
back: 'Geri',
|
||||||
|
searchPlaceholder: 'Oturum, görünüm ve işlem ara',
|
||||||
|
goTo: 'Git',
|
||||||
|
goToSession: 'Oturuma git',
|
||||||
|
branches: 'Dallar',
|
||||||
|
projects: 'Projeler',
|
||||||
|
openFolder: 'Klasörü proje olarak aç…',
|
||||||
|
openFolderAt: path => `Klasörü proje olarak aç — ${path}`,
|
||||||
|
newSessionInProject: project => `${project} projesinde yeni oturum`,
|
||||||
|
commands: 'Komutlar',
|
||||||
|
startInBranch: branch => `${branch} dalında yeni sohbet`,
|
||||||
|
commandCenter: 'Komut merkezi',
|
||||||
|
appearance: 'Görünüm',
|
||||||
|
settings: 'Ayarlar',
|
||||||
|
changeTheme: 'Temayı değiştir',
|
||||||
|
changeColorMode: 'Renk modunu değiştir…',
|
||||||
|
pets: {
|
||||||
|
title: 'Maskotlar',
|
||||||
|
placeholder: 'Maskot ara…',
|
||||||
|
loading: 'Maskot galerisi yükleniyor…',
|
||||||
|
error: 'Maskot galerisine erişilemedi.',
|
||||||
|
staleBackend:
|
||||||
|
'Maskotları kullanmak için AITURK’ü yeniden başlatın; çalışan sunucu bu özelliği henüz desteklemiyor.',
|
||||||
|
empty: 'Eşleşen maskot yok.',
|
||||||
|
turnOff: 'Kapat',
|
||||||
|
turnOn: 'Aç',
|
||||||
|
installed: 'Kurulu',
|
||||||
|
generatedTag: 'Oluşturulan',
|
||||||
|
adoptFailed: 'Maskot eklenemedi.',
|
||||||
|
toggleFailed: enabled => `Maskot ${enabled ? 'açılamadı' : 'kapatılamadı'}.`,
|
||||||
|
noneAvailable: 'Kullanılabilir maskot yok; kurmak için aşağıdan birini seçin.'
|
||||||
|
},
|
||||||
|
generatePet: {
|
||||||
|
title: 'Maskot oluştur',
|
||||||
|
placeholder: 'Oluşturulacak maskotu tarif edin…',
|
||||||
|
promptHint: 'Dört görünüm taslağı oluşturmak için açıklama yazıp Enter’a basın.',
|
||||||
|
readyHint: 'Açıklamanızdan dört görünüm taslağı oluşturmak için Enter’a basın.',
|
||||||
|
generate: 'Oluştur',
|
||||||
|
generating: 'Oluşturuluyor…',
|
||||||
|
retry: 'Yeniden dene',
|
||||||
|
hatch: 'Canlandır',
|
||||||
|
spawning: 'Hazırlanıyor…',
|
||||||
|
hatching: 'Maskotunuz canlandırılıyor…',
|
||||||
|
hatchingSub: 'Hayat buluyor…',
|
||||||
|
hatched: 'Hazır!',
|
||||||
|
hatchRow: (_state, done, total) => `${total} kareden ${done}. kare çiziliyor…`,
|
||||||
|
hatchComposing: 'Kareler birleştiriliyor…',
|
||||||
|
hatchSaving: 'Neredeyse hazır…',
|
||||||
|
namePlaceholder: 'Maskotunuza ad verin',
|
||||||
|
staleBackend: 'Maskot oluşturmak için AITURK’ü güncelleyin.',
|
||||||
|
backgroundHint: 'Bu pencereyi kapatabilirsiniz; AITURK işlem tamamlanınca size haber verir.',
|
||||||
|
slowProviderHint: 'Bu işlem birkaç dakika sürebilir',
|
||||||
|
remix: 'Yeni çeşitleme',
|
||||||
|
remixConfirmTitle: 'Bu görünümden yeni çeşitlemeler oluşturulsun mu?',
|
||||||
|
remixConfirmBody:
|
||||||
|
'Bu görünüm başlangıç olarak kullanılarak yeni taslaklar oluşturulur. İşlem birkaç dakika sürebilir.',
|
||||||
|
genericError: 'Oluşturma başarısız oldu; yeniden deneyin veya bir öneri seçin.',
|
||||||
|
referenceImageTooLarge: 'Referans görsel çok büyük. 16 MB’tan küçük bir görsel kullanın.',
|
||||||
|
referenceImageInvalid: 'Referans görsel okunamadı. PNG, JPG, WebP veya GIF deneyin.',
|
||||||
|
adopt: 'Ekle',
|
||||||
|
startOver: 'Baştan başla'
|
||||||
|
},
|
||||||
|
installTheme: {
|
||||||
|
title: 'Tema kur…',
|
||||||
|
pageTitle: 'Tema kur',
|
||||||
|
placeholder: 'VS Code Marketplace içinde ara…',
|
||||||
|
loading: 'Mağazada aranıyor…',
|
||||||
|
error: 'Mağazaya erişilemedi.',
|
||||||
|
empty: 'Eşleşen tema yok.',
|
||||||
|
install: 'Kur',
|
||||||
|
installing: 'Kuruluyor…',
|
||||||
|
installed: 'Kurulu',
|
||||||
|
installs: count => `${count} kurulum`
|
||||||
|
},
|
||||||
|
settingsFields: 'Ayar alanları',
|
||||||
|
mcpServers: 'MCP sunucuları',
|
||||||
|
archivedChats: 'Arşivlenmiş sohbetler',
|
||||||
|
sections: { maintenance: 'Bakım', sessions: 'Oturumlar', system: 'Sistem', usage: 'Kullanım' },
|
||||||
|
sectionDescriptions: {
|
||||||
|
maintenance: 'Tanılama, yedekler, beceri düzenleyicisi ve bellek verileri',
|
||||||
|
sessions: 'Oturumları ara ve yönet',
|
||||||
|
system: 'Durum, günlükler ve sistem işlemleri',
|
||||||
|
usage: 'Zamana göre belirteç, maliyet ve beceri kullanımı'
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
newChat: { title: 'Yeni oturum', detail: 'Yeni bir oturum başlat' },
|
||||||
|
settings: { title: 'Ayarlar', detail: 'AITURK masaüstünü yapılandır' },
|
||||||
|
skills: { title: 'Yetenekler', detail: 'Beceriler, araçlar ve MCP sunucuları' },
|
||||||
|
messaging: { title: 'Mesajlaşma', detail: 'Telegram, Slack, Discord ve diğer platformları ayarla' },
|
||||||
|
artifacts: { title: 'Çıktılar', detail: 'Oluşturulan çıktılara göz at' }
|
||||||
|
},
|
||||||
|
sectionEntries: {
|
||||||
|
sessions: { title: 'Oturum paneli', detail: 'Oturumları ara, sabitle ve yönet' },
|
||||||
|
system: { title: 'Sistem paneli', detail: 'Ağ geçidi durumu, günlükler, yeniden başlatma ve güncelleme' },
|
||||||
|
usage: { title: 'Kullanım paneli', detail: 'Belirteç, maliyet ve beceri kullanımı' }
|
||||||
|
},
|
||||||
|
providerNavigate: 'Gezinme',
|
||||||
|
providerSessions: 'Oturumlar',
|
||||||
|
refresh: 'Yenile',
|
||||||
|
refreshing: 'Yenileniyor…',
|
||||||
|
noResults: 'Eşleşen sonuç bulunamadı.',
|
||||||
|
pinSession: 'Oturumu sabitle',
|
||||||
|
unpinSession: 'Oturum sabitlemesini kaldır',
|
||||||
|
exportSession: 'Oturumu dışa aktar',
|
||||||
|
deleteSession: 'Oturumu sil',
|
||||||
|
noSessions: 'Henüz oturum yok.',
|
||||||
|
gatewayRunning: 'Mesajlaşma ağ geçidi çalışıyor',
|
||||||
|
gatewayStopped: 'Mesajlaşma ağ geçidi durdu',
|
||||||
|
hermesActiveSessions: (version, count) => `AITURK ${version} · ${count} etkin oturum`,
|
||||||
|
restartGateway: 'Ağ geçidini yeniden başlat',
|
||||||
|
openBrowser: 'Tarayıcıyı aç',
|
||||||
|
gatewayRestartFailed: 'Ağ geçidi yeniden başlatılamadı.',
|
||||||
|
updateHermes: 'AITURK’ü güncelle',
|
||||||
|
reloadWindow: 'Pencereyi yeniden yükle',
|
||||||
|
actionRunning: 'çalışıyor',
|
||||||
|
actionDone: 'tamamlandı',
|
||||||
|
actionFailed: 'başarısız',
|
||||||
|
actionStartedWaiting: 'İşlem başladı, durum bekleniyor…',
|
||||||
|
loadingStatus: 'Durum yükleniyor…',
|
||||||
|
recentLogs: 'Son günlükler',
|
||||||
|
noLogs: 'Henüz günlük yüklenmedi.',
|
||||||
|
days: count => `${count} gün`,
|
||||||
|
statSessions: 'Oturumlar',
|
||||||
|
statApiCalls: 'API çağrıları',
|
||||||
|
statTokens: 'Girdi/çıktı belirteçleri',
|
||||||
|
statCost: 'Tahmini maliyet',
|
||||||
|
actualCost: cost => `gerçek ${cost}`,
|
||||||
|
loadingUsage: 'Kullanım yükleniyor…',
|
||||||
|
noUsage: period => `Son ${period} günde kullanım yok.`,
|
||||||
|
retry: 'Yeniden dene',
|
||||||
|
dailyTokens: 'Günlük belirteçler',
|
||||||
|
input: 'girdi',
|
||||||
|
output: 'çıktı',
|
||||||
|
noDailyActivity: 'Günlük etkinlik yok.',
|
||||||
|
topModels: 'En çok kullanılan modeller',
|
||||||
|
noModelUsage: 'Henüz model kullanımı yok.',
|
||||||
|
topSkills: 'En çok kullanılan beceriler',
|
||||||
|
noSkillActivity: 'Henüz beceri kullanımı yok.',
|
||||||
|
actions: count => `${count} işlem`,
|
||||||
|
logFile: 'Günlük dosyası',
|
||||||
|
logLevel: 'Düzey',
|
||||||
|
logSearchPlaceholder: 'Günlük satırlarını filtrele…',
|
||||||
|
maintenance: {
|
||||||
|
runOps: 'Tanılama',
|
||||||
|
doctor: 'Sağlık denetimini çalıştır',
|
||||||
|
doctorDesc: 'Kurulumu, ayarları ve sağlayıcıları denetler',
|
||||||
|
securityAudit: 'Güvenlik denetimi',
|
||||||
|
securityAuditDesc: 'Ayarları ve becerileri riskli yapılandırmalar için tarar',
|
||||||
|
backup: 'Yedek oluştur',
|
||||||
|
backupDesc: 'Ayarları, belleği, becerileri ve oturumları ZIP dosyasına alır',
|
||||||
|
debugShare: 'Tanılama raporunu paylaş',
|
||||||
|
debugShareDesc:
|
||||||
|
'Gizli bilgiler maskelenmiş raporu ve günlükleri yükleyip paylaşım bağlantıları oluşturur; 6 saat sonra otomatik silinir',
|
||||||
|
debugShareRunning: 'Tanılama raporu yükleniyor…',
|
||||||
|
debugShareLinks: 'Paylaşım bağlantıları',
|
||||||
|
debugShareFailed: 'Tanılama raporu paylaşılamadı',
|
||||||
|
copyLink: 'Bağlantıyı kopyala',
|
||||||
|
linkCopied: 'Bağlantı kopyalandı',
|
||||||
|
curator: 'Beceri düzenleyicisi',
|
||||||
|
curatorDesc: 'Ajanın oluşturduğu eski becerileri arka planda inceleyip arşivler',
|
||||||
|
curatorPaused: 'Duraklatıldı',
|
||||||
|
curatorActive: 'Etkin',
|
||||||
|
curatorDisabled: 'Devre dışı',
|
||||||
|
curatorLastRun: when => `Son çalıştırma: ${when}`,
|
||||||
|
curatorNeverRan: 'Hiç çalıştırılmadı',
|
||||||
|
pause: 'Duraklat',
|
||||||
|
resume: 'Sürdür',
|
||||||
|
runNow: 'Şimdi çalıştır',
|
||||||
|
memoryData: 'Bellek verileri',
|
||||||
|
memoryDataDesc: 'Her oturuma eklenen yerleşik bellek dosyaları',
|
||||||
|
memoryProvider: name => `Etkin sağlayıcı: ${name}`,
|
||||||
|
builtinMemory: 'yerleşik',
|
||||||
|
memoryFile: 'Ajan belleği (MEMORY.md)',
|
||||||
|
userFile: 'Kullanıcı profili (USER.md)',
|
||||||
|
bytes: size => size,
|
||||||
|
empty: 'boş',
|
||||||
|
resetMemory: 'Belleği sıfırla',
|
||||||
|
resetUser: 'Profili sıfırla',
|
||||||
|
resetAll: 'İkisini de sıfırla',
|
||||||
|
resetConfirm: target => `${target} silinsin mi? Bu işlem geri alınamaz.`,
|
||||||
|
resetDone: files => `Silindi: ${files}.`,
|
||||||
|
resetFailed: 'Bellek sıfırlanamadı',
|
||||||
|
actionStarted: name => `${name} başladı; günlük izleniyor…`,
|
||||||
|
actionFailed: name => `${name} başlatılamadı`,
|
||||||
|
running: 'Çalışıyor…',
|
||||||
|
viewLog: 'İşlem günlüğü'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TR_ARTIFACTS: Translations['artifacts'] = {
|
||||||
|
search: 'Çıktılarda ara…',
|
||||||
|
refresh: 'Çıktıları yenile',
|
||||||
|
refreshing: 'Çıktılar yenileniyor',
|
||||||
|
indexing: 'Son oturumların çıktıları dizine ekleniyor',
|
||||||
|
tabAll: 'Tümü',
|
||||||
|
tabImages: 'Görseller',
|
||||||
|
tabFiles: 'Dosyalar',
|
||||||
|
tabLinks: 'Bağlantılar',
|
||||||
|
noArtifactsTitle: 'Çıktı bulunamadı',
|
||||||
|
noArtifactsDesc: 'Oturumlarda oluşturulan görseller ve dosyalar burada görünür.',
|
||||||
|
failedLoad: 'Çıktılar yüklenemedi',
|
||||||
|
openFailed: 'Açılamadı',
|
||||||
|
itemsImage: 'görseller',
|
||||||
|
itemsLink: 'bağlantılar',
|
||||||
|
itemsFile: 'dosyalar',
|
||||||
|
itemsGeneric: 'öğeler',
|
||||||
|
zero: '0',
|
||||||
|
rangeOf: (start, end, total) => `${start}–${end} / ${total}`,
|
||||||
|
goToPage: (itemLabel, page) => `${itemLabel}: ${page}. sayfaya git`,
|
||||||
|
colTitleLink: 'Bağlantı başlığı',
|
||||||
|
colTitleFile: 'Ad',
|
||||||
|
colTitleDefault: 'Başlık / ad',
|
||||||
|
colLocationLink: 'Adres',
|
||||||
|
colLocationFile: 'Yol',
|
||||||
|
colLocationDefault: 'Konum',
|
||||||
|
colSession: 'Oturum',
|
||||||
|
kindImage: 'görsel',
|
||||||
|
kindFile: 'dosya',
|
||||||
|
kindLink: 'bağlantı',
|
||||||
|
chat: 'Sohbet',
|
||||||
|
copyUrl: 'Adresi kopyala',
|
||||||
|
copyPath: 'Yolu kopyala'
|
||||||
|
}
|
||||||
|
export const TR_ARTIFACT_CARD: Translations['artifactCard'] = {
|
||||||
|
kind: { code: 'Kod', html: 'Etkileşimli sayfa', svg: 'Grafik' },
|
||||||
|
generating: lines => `Oluşturuluyor… ${lines} satır`,
|
||||||
|
versionBadge: count => `${count} sürüm`,
|
||||||
|
open: 'Aç'
|
||||||
|
}
|
||||||
|
export const TR_ARTIFACT_PREVIEW: Translations['artifactPreview'] = {
|
||||||
|
versionOf: (current, total) => `${current}. sürüm / ${total}`,
|
||||||
|
olderVersion: 'Önceki sürüm',
|
||||||
|
newerVersion: 'Sonraki sürüm',
|
||||||
|
latest: 'En yeni',
|
||||||
|
copyContent: 'İçeriği kopyala',
|
||||||
|
download: 'İndir',
|
||||||
|
openInBrowser: 'Tarayıcıda aç',
|
||||||
|
openInBrowserFailed: 'Tarayıcıda açılamadı',
|
||||||
|
missingTitle: 'Çıktı kullanılamıyor',
|
||||||
|
missingBody: 'Bu çıktı artık yerel kayıtlarda bulunmuyor.'
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { TranslationOverrides } from './define-locale'
|
||||||
|
|
||||||
|
export const TR_CONNECTION_SETTINGS = {
|
||||||
|
connections: {
|
||||||
|
title: 'Kayıtlı ağ geçitleri',
|
||||||
|
intro: 'Bu cihazı ve uzaktan, SSH veya bulut bağlantısıyla erişilen ajan sunucularını yönetin.',
|
||||||
|
stagedNote:
|
||||||
|
'Ağ geçidini Oturumlar bölümünden değiştirin. Profiller, sohbetler, mesajlaşma ve zamanlanmış işler kendi ağ geçitlerinde kalır. Diğer ağ geçitlerindeki işler devam eder.',
|
||||||
|
launchModeTitle: 'Açılışta son kullanılan ağ geçidinin oturumlarına dön',
|
||||||
|
launchModeDesc: 'Kapalıysa Oturumlar, birincil ağ geçidinde açılır.',
|
||||||
|
searchPlaceholder: 'Ağ geçidi ara…',
|
||||||
|
noSearchResults: 'Eşleşen ağ geçidi yok.',
|
||||||
|
loadFailed: 'Bağlantılar yüklenemedi',
|
||||||
|
currentPill: 'Geçerli',
|
||||||
|
primaryPill: 'Birincil',
|
||||||
|
managedPill: 'Uygulama yönetiyor',
|
||||||
|
addConnection: 'Bağlantı ekle',
|
||||||
|
editConnection: 'Düzenle',
|
||||||
|
removeConnection: 'Kaldır',
|
||||||
|
removeConfirmTitle: 'Bu bağlantı kaldırılsın mı?',
|
||||||
|
removeConfirmDesc: label =>
|
||||||
|
`“${label}” bu uygulamadan kaldırılacak. Sunucunun kendisi değişmez; bağlantıyı daha sonra tekrar ekleyebilirsiniz.`,
|
||||||
|
makePrimary: 'Birincil yap',
|
||||||
|
testConnection: 'Dene',
|
||||||
|
testOk: 'Erişilebilir',
|
||||||
|
testFailed: 'Bağlantı testi başarısız',
|
||||||
|
saveFailed: 'Bağlantı kaydedilemedi',
|
||||||
|
removeFailed: 'Bağlantı kaldırılamadı',
|
||||||
|
updateAll: 'Tüm kurulumları güncelle',
|
||||||
|
updateAllRunning: 'Tüm kurulumlar güncelleniyor…',
|
||||||
|
updateAllDone: 'Güncelleme istekleri gönderildi',
|
||||||
|
updateAllFailed: 'Toplu güncelleme başlatılamadı',
|
||||||
|
updateSkippedCloud: 'Hermes Cloud yönetiyor',
|
||||||
|
kindLocal: 'Yerel',
|
||||||
|
kindRemote: 'Uzak ağ geçidi',
|
||||||
|
kindCloud: 'Hermes Cloud',
|
||||||
|
kindSsh: 'SSH',
|
||||||
|
kindLocalDesc: 'Bu uygulamanın yönettiği yerel ajan ortamı.',
|
||||||
|
kindRemoteDesc: 'Yerel ağ, Tailscale veya internet üzerinden HTTP(S) ile erişilen ajan sunucusu.',
|
||||||
|
kindCloudDesc: 'Hermes Cloud hesabınız üzerinden bulunan bulut kurulumu.',
|
||||||
|
kindSshDesc: 'SSH ile erişilen ajan kurulumu.',
|
||||||
|
labelTitle: 'Ad',
|
||||||
|
labelDesc:
|
||||||
|
'Zorunludur ve benzersiz olmalıdır. Kurulumun göründüğü her yerde bu ad kullanılır; örneğin “Ev sunucusu” veya “İş bilgisayarı”.',
|
||||||
|
labelPlaceholder: 'Ev sunucusu',
|
||||||
|
urlTitle: 'Ağ geçidi adresi',
|
||||||
|
sshHostTitle: 'SSH sunucusu',
|
||||||
|
headersTitle: 'Ek ağ geçidi başlıkları',
|
||||||
|
headersDesc:
|
||||||
|
'Bu ağ geçidine gönderilen her HTTP ve WebSocket isteğine eklenir. Cloudflare Access gibi erişim hizmetlerinde kullanılabilir. Değerler şifrelenir. Uygulamanın yönettiği Authorization, Cookie ve Host başlıkları yok sayılır.',
|
||||||
|
headerValuePlaceholder: 'Değer',
|
||||||
|
headerValueSaved: 'Kayıtlı değeri korumak için boş bırakın',
|
||||||
|
headerAdd: 'Başlık ekle',
|
||||||
|
headerRemove: 'Kaldır',
|
||||||
|
duplicateLocal: 'Uygulama zaten bir yerel bağlantı yönetiyor. Yalnızca bir tane olabilir.',
|
||||||
|
duplicateUrl: label => `Bu ağ geçidi adresine ait bağlantı zaten var: “${label}”.`,
|
||||||
|
duplicateSsh: label => `Bu SSH sunucusuna ait bağlantı zaten var: “${label}”.`,
|
||||||
|
sameBackendHint: label => `“${label}” ile aynı sunucu`,
|
||||||
|
localAddHint: 'Yönetilen yerel bağlantı zaten bulunduğundan yeni bir yerel bağlantı eklenemez.',
|
||||||
|
cloudAddHint:
|
||||||
|
'Hermes Cloud’a giriş yaptığınızda ajanlarınız otomatik bulunur. Bu formu yalnızca bildiğiniz bir kurulum adresini elle kaydetmek için kullanın.',
|
||||||
|
save: 'Bağlantıyı kaydet',
|
||||||
|
saving: 'Kaydediliyor…',
|
||||||
|
cancel: 'Vazgeç',
|
||||||
|
empty: 'Henüz kayıtlı bağlantı yok.'
|
||||||
|
},
|
||||||
|
managedUpdates: {
|
||||||
|
title: 'Yönetilen güncellemeler',
|
||||||
|
intro:
|
||||||
|
'Uygulamanın yönettiği SSH kurulumlarını günceller. Oturumların durulması beklenir, uzak kaynak güncellenir ve profiller işlem kaydıyla geri yüklenir.',
|
||||||
|
sshConnection: 'Uygulamanın yönettiği SSH kurulumu',
|
||||||
|
update: 'Güncelle',
|
||||||
|
updating: 'Güncelleniyor…',
|
||||||
|
progress: 'Oturumlar bekleniyor, uzak kurulum güncelleniyor ve profiller geri yükleniyor…',
|
||||||
|
updated: 'Güncellendi',
|
||||||
|
partial: 'Güncellendi; geri yükleme başarısız',
|
||||||
|
refused: 'Reddedildi',
|
||||||
|
failed: 'Güncelleme başarısız',
|
||||||
|
alreadyRunning: 'Güncelleme zaten sürüyor',
|
||||||
|
receipt: (id, outcome) => `İşlem kaydı ${id} · ${outcome}`,
|
||||||
|
receiptVersions: (pre, post) => `${pre} → ${post}`,
|
||||||
|
scopesRestored: profiles => `Geri yüklenen profiller: ${profiles}`,
|
||||||
|
scopeNotRestored: (profile, error) => `“${profile}” profili geri yüklenemedi: ${error}`
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
loading: 'Ağ geçidi ayarları yükleniyor…',
|
||||||
|
unavailableTitle: 'Ağ geçidi ayarları kullanılamıyor',
|
||||||
|
unavailableDesc: 'Masaüstü iletişim köprüsü ağ geçidi ayarlarını sunmuyor.',
|
||||||
|
title: 'Ağ geçidi bağlantısı',
|
||||||
|
envOverride: 'ortam değişkeni öncelikli',
|
||||||
|
intro:
|
||||||
|
'Varsayılan bağlantı yereldir. Başka bir makinedeki ajanı yönetmek için uzak bağlantı kullanın. Bağlantılar cihaza özeldir; profiller bağlandığınız ağ geçidinden bulunur.',
|
||||||
|
envOverrideTitle: 'Bu masaüstü oturumunu ortam değişkenleri yönetiyor.',
|
||||||
|
envOverrideDesc:
|
||||||
|
'Kayıtlı ayarları kullanmak için HERMES_DESKTOP_REMOTE_URL ve HERMES_DESKTOP_REMOTE_TOKEN ortam değişkenlerini kaldırın.',
|
||||||
|
modeTitle: 'Bağlantı modu',
|
||||||
|
localTitle: 'Yerel ağ geçidi',
|
||||||
|
localDesc: 'Bu bilgisayarda özel bir ajan sunucusu başlatır. Varsayılandır ve çevrimdışı çalışabilir.',
|
||||||
|
remoteTitle: 'Uzak ağ geçidi',
|
||||||
|
remoteDesc: 'Masaüstü arayüzünü uzaktaki bir ajan sunucusuna bağlar.',
|
||||||
|
remoteAuthHint:
|
||||||
|
'Barındırılan ağ geçitleri OAuth veya kullanıcı adı ve parola kullanır. Kendi sunucunuz oturum anahtarı kullanabilir.',
|
||||||
|
cloudTitle: 'Hermes Cloud',
|
||||||
|
cloudDesc: 'Hesabınızdaki ajanları görmek için Hermes Cloud’a giriş yapın. Adres yapıştırmanız gerekmez.',
|
||||||
|
cloudSignInTitle: 'Hermes Cloud',
|
||||||
|
cloudSignIn: 'Hermes Cloud’a giriş yap',
|
||||||
|
cloudSignedIn: 'Hermes Cloud’a giriş yapıldı',
|
||||||
|
cloudNeedsSignIn: 'Hesabınızdaki ajanları bulmak için Hermes Cloud’a giriş yapın.',
|
||||||
|
cloudSignedInDesc: 'Giriş yaptınız. Aşağıdan bir ajan seçin; oturum otomatik yenilenir.',
|
||||||
|
cloudAgentsTitle: 'Ajanlarınız',
|
||||||
|
cloudOrgPickerTitle: 'Kuruluş seçin',
|
||||||
|
cloudOrgSelect: 'Seç',
|
||||||
|
cloudOrgChange: 'Kuruluşu değiştir',
|
||||||
|
cloudOrgRole: role => `Rol: ${role}`,
|
||||||
|
cloudLoadingAgents: 'Ajanlarınız yükleniyor…',
|
||||||
|
cloudNoAgents: {
|
||||||
|
before: 'Bu hesapta ajan bulunamadı. ',
|
||||||
|
linkText: 'Nous portalında',
|
||||||
|
after: ' bir ajan oluşturup yenileyin.'
|
||||||
|
},
|
||||||
|
cloudRefresh: 'Yenile',
|
||||||
|
cloudConnect: 'Bağlan',
|
||||||
|
cloudConnecting: 'Bağlanıyor…',
|
||||||
|
cloudDiscoverFailed: 'Hermes Cloud ajanları yüklenemedi',
|
||||||
|
cloudConnectFailed: 'Ajana bağlanılamadı',
|
||||||
|
cloudSignInFailed: 'Hermes Cloud’a giriş yapılamadı',
|
||||||
|
cloudSignedOutTitle: 'Hermes Cloud’dan çıkış yapıldı',
|
||||||
|
cloudSignedOutMessage: 'Hermes Cloud oturumu temizlendi.',
|
||||||
|
cloudConnectedTitle: 'Bağlandı',
|
||||||
|
cloudConnectedPill: 'Bağlı',
|
||||||
|
cloudConnectedTo: name => `${name} ajanına bağlandı.`,
|
||||||
|
cloudAgentProvisioning: 'Hazırlanıyor…',
|
||||||
|
cloudStatusLabel: status => `Durum: ${status}`,
|
||||||
|
remoteUrlTitle: 'Uzak adres',
|
||||||
|
remoteUrlDesc: 'Uzak yönetim sunucusunun temel adresi. /hermes gibi yol önekleri desteklenir.',
|
||||||
|
probing: 'Ağ geçidinin kimlik doğrulama yöntemi denetleniyor…',
|
||||||
|
probeError: 'Ağ geçidine henüz ulaşılamadı. Adresi kontrol edin; yanıt verdiğinde giriş yöntemi görünecek.',
|
||||||
|
signedIn: 'Giriş yapıldı',
|
||||||
|
signIn: 'Giriş yap',
|
||||||
|
signOut: 'Çıkış yap',
|
||||||
|
signInWith: provider => `${provider} ile giriş yap`,
|
||||||
|
authTitle: 'Kimlik doğrulama',
|
||||||
|
authSignedInPassword: 'Bu ağ geçidi kullanıcı adı ve parola kullanıyor. Giriş yaptınız; oturum otomatik yenilenir.',
|
||||||
|
authSignedInOauth: 'Bu ağ geçidi OAuth kullanıyor. Giriş yaptınız; oturum otomatik yenilenir.',
|
||||||
|
authNeedsPassword:
|
||||||
|
'Bu ağ geçidi kullanıcı adı ve parola kullanıyor. Masaüstü uygulamasını yetkilendirmek için giriş yapın.',
|
||||||
|
authNeedsOauth: provider =>
|
||||||
|
`Bu ağ geçidi OAuth kullanıyor. Uygulamayı yetkilendirmek için ${provider} ile giriş yapın.`,
|
||||||
|
tokenTitle: 'Oturum anahtarı',
|
||||||
|
tokenDesc:
|
||||||
|
'REST ve WebSocket erişimi için yönetim panelinin oturum anahtarı. Kayıtlı anahtarı korumak için boş bırakın.',
|
||||||
|
existingToken: value => `Kayıtlı anahtar ${value}`,
|
||||||
|
savedToken: 'kayıtlı',
|
||||||
|
pasteSessionToken: 'Oturum anahtarını yapıştır',
|
||||||
|
plainTextConfirmTitle: 'Ağ geçidi anahtarı şifrelenmeden saklansın mı?',
|
||||||
|
plainTextConfirmDesc:
|
||||||
|
'Bu bilgisayarda işletim sistemi anahtar deposu bulunamadı. Anahtar bağlantı ayarları dosyasında şifrelenmeden saklanacak; aynı kullanıcı hesabıyla çalışan işlemler okuyabilir. Şifreli saklama için GNOME Keyring veya KWallet kurun ya da etkinleştirin.',
|
||||||
|
plainTextConfirmAction: 'Şifrelemeden kaydet',
|
||||||
|
plainTextStoredTitle: 'Anahtar şifrelenmeden saklanıyor',
|
||||||
|
plainTextStoredDesc:
|
||||||
|
'Güvenli depo kullanılamadığından anahtar bu bilgisayarın bağlantı ayarlarında şifrelenmeden saklanıyor. Şifrelemek için GNOME Keyring veya KWallet etkinleştirin.',
|
||||||
|
keychainEncryptionTitle: 'Kayıtlı gizli bilgileri sistem anahtar deposuyla şifrele',
|
||||||
|
keychainEncryptionDesc:
|
||||||
|
'Varsayılan olarak kapalıdır. Açıldığında bağlantı anahtarları ve giriş bilgileri Keychain, GNOME Keyring veya Windows DPAPI ile şifrelenir; sistem izin ya da parola isteyebilir. Kapalıyken yalnızca kullanıcı hesabınızın okuyabildiği dosyalarda saklanır.',
|
||||||
|
keychainEncryptionFailed: 'Gizli bilgi şifrelemesi değiştirilemedi',
|
||||||
|
testRemote: 'Uzak bağlantıyı dene',
|
||||||
|
saveForRestart: 'Sonraki açılış için kaydet',
|
||||||
|
saveAndReconnect: 'Kaydet ve yeniden bağlan',
|
||||||
|
diagnostics: 'Tanılama',
|
||||||
|
diagnosticsDesc: 'Dosya yöneticisinde desktop.log günlüğünü gösterir. Ağ geçidi başlamadığında yararlıdır.',
|
||||||
|
openLogs: 'Günlükleri aç',
|
||||||
|
incompleteTitle: 'Uzak ağ geçidi bilgileri eksik',
|
||||||
|
incompleteSignIn: 'Uzak moda geçmeden önce adresi girip giriş yapın.',
|
||||||
|
incompleteToken: 'Uzak moda geçmeden önce adresi ve oturum anahtarını girin.',
|
||||||
|
incompleteSignInTest: 'Denemeden önce uzak adresi girip giriş yapın.',
|
||||||
|
incompleteTokenTest: 'Denemeden önce uzak adresi ve oturum anahtarını girin.',
|
||||||
|
enterUrlFirst: 'Önce uzak adresi girin.',
|
||||||
|
restartingTitle: 'Ağ geçidi bağlantısı yeniden başlatılıyor',
|
||||||
|
savedTitle: 'Ağ geçidi ayarları kaydedildi',
|
||||||
|
restartingMessage: 'Arayüz açık kalacak ve kayıtlı ayarlarla yeniden bağlanacak.',
|
||||||
|
savedMessage: 'Sonraki açılış için kaydedildi.',
|
||||||
|
connectedTo: (baseUrl, version) => `${baseUrl} adresine bağlandı${version ? ` · Hermes ${version}` : ''}`,
|
||||||
|
reachableTitle: 'Uzak ağ geçidine erişildi',
|
||||||
|
signedOutTitle: 'Çıkış yapıldı',
|
||||||
|
signedOutMessage: 'Uzak ağ geçidi oturumu temizlendi.',
|
||||||
|
failedLoad: 'Ağ geçidi ayarları yüklenemedi',
|
||||||
|
signInFailed: 'Giriş yapılamadı',
|
||||||
|
signOutFailed: 'Çıkış yapılamadı',
|
||||||
|
testFailed: 'Uzak ağ geçidi testi başarısız',
|
||||||
|
applyFailed: 'Ağ geçidi ayarları uygulanamadı',
|
||||||
|
saveFailed: 'Ağ geçidi ayarları kaydedilemedi',
|
||||||
|
sshTitle: 'SSH ile bağlan',
|
||||||
|
sshDesc:
|
||||||
|
'Ajan uzaktaki bilgisayarda SSH ile başlatılır ve bu uygulamaya tünellenir. Sunucuyu elle açmanız gerekmez. Anahtar tabanlı çalışan SSH erişimi gerekir.',
|
||||||
|
sshTrustHint: 'İlk sunucu anahtarı kaydedilip sabitlenir. Daha sonra değişirse bağlantı reddedilir.',
|
||||||
|
sshHostTitle: 'Sunucu',
|
||||||
|
sshHostDesc: 'kullanıcı@sunucu veya ~/.ssh/config dosyasındaki sunucu takma adı.',
|
||||||
|
sshHostPick: 'Sunucu seçin…',
|
||||||
|
sshHostPickTitle: 'Sunucu',
|
||||||
|
sshHostPickDesc: '~/.ssh/config dosyasındaki bir sunucu veya elle girmek için Özel.',
|
||||||
|
sshHostCustom: 'Özel; elle gir…',
|
||||||
|
sshUserTitle: 'Kullanıcı',
|
||||||
|
sshUserDesc: 'Boş bırakılırsa ~/.ssh/config veya mevcut kullanıcı kullanılır.',
|
||||||
|
sshUserPlaceholder: '~/.ssh/config dosyasından',
|
||||||
|
sshPortTitle: 'Bağlantı noktası',
|
||||||
|
sshPortDesc: 'Boş bırakılırsa 22 veya ~/.ssh/config portu kullanılır.',
|
||||||
|
sshKeyTitle: 'Kimlik dosyası',
|
||||||
|
sshKeyDesc: 'Özel anahtarın yolu. Boş bırakılırsa ssh-agent veya ~/.ssh/config kullanılır.',
|
||||||
|
sshHermesPathTitle: 'Hermes yolu (isteğe bağlı)',
|
||||||
|
sshHermesPathDesc: 'Uzak hermes komutunun tam yolu. Boş bırakılırsa otomatik bulunur.',
|
||||||
|
sshHermesPathPlaceholder: 'otomatik algıla',
|
||||||
|
sshTestConnection: 'SSH bağlantısını dene',
|
||||||
|
sshConnect: 'Bağlan',
|
||||||
|
sshButtonsHint: 'Kaydet sonraki açılışta uygulanır. Bağlan hemen yeniden bağlanır.',
|
||||||
|
sshReachable: (host, platform) => `Erişilebilir: ${host} (${platform}) · Hermes bulundu`,
|
||||||
|
sshIncompleteHost: 'Bağlanmadan önce SSH sunucusunu girin.',
|
||||||
|
sshErrUnreachable: 'Sunucuya SSH üzerinden ulaşılamadı. Adresi, portu ve ağ bağlantınızı kontrol edin.',
|
||||||
|
sshErrAuth:
|
||||||
|
'SSH kimlik doğrulaması başarısız. Anahtarı ssh-add ile ssh-agent’a ekleyin veya ~/.ssh/config içinde IdentityFile belirtin.',
|
||||||
|
sshErrHostKey:
|
||||||
|
'Sunucu anahtarı son bağlantıdan beri DEĞİŞTİ. Bunun beklenen bir değişiklik olduğunu doğruladıktan sonra ssh-keygen -R <sunucu> çalıştırıp yeniden bağlanın.',
|
||||||
|
sshErrNotInstalled: 'Uzak sunucuda Hermes kurulu değil. Önce sunucuya kurun veya Hermes komutunun yolunu belirtin.',
|
||||||
|
sshErrPlatform: 'Uzak platform desteklenmiyor. SSH modu Linux, macOS ve Windows sunucularını destekler.',
|
||||||
|
sshErrTimeout: 'SSH bağlantısı zaman aşımına uğradı. Sunucuya erişilemiyor veya sunucu uykuda olabilir.',
|
||||||
|
sshErrUpdateRequired: 'Masaüstünden SSH ile bağlanmadan önce uzak sunucudaki Hermes’i güncelleyin.',
|
||||||
|
sshErrUnknown: 'SSH bağlantısı başarısız.'
|
||||||
|
}
|
||||||
|
} satisfies NonNullable<TranslationOverrides['settings']>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { Translations } from './types'
|
||||||
|
|
||||||
|
export const TR_UPDATES: Translations['updates'] = {
|
||||||
|
stages: {
|
||||||
|
idle: 'Hazırlanıyor…',
|
||||||
|
prepare: 'Hazırlanıyor…',
|
||||||
|
fetch: 'İndiriliyor…',
|
||||||
|
pull: 'Neredeyse hazır…',
|
||||||
|
pydeps: 'Son işlemler yapılıyor…',
|
||||||
|
update: 'AITURK güncelleniyor…',
|
||||||
|
rebuild: 'Masaüstü uygulaması yeniden derleniyor…',
|
||||||
|
restart: 'AITURK yeniden başlatılıyor…',
|
||||||
|
done: 'Güncelleme tamamlandı',
|
||||||
|
manual: 'Terminalden güncelleyin',
|
||||||
|
guiSkew: 'Masaüstü uygulamasını güncelleyin',
|
||||||
|
error: 'Güncelleme durakladı'
|
||||||
|
},
|
||||||
|
checking: 'Güncellemeler denetleniyor…',
|
||||||
|
checkFailedTitle: 'Güncellemeler denetlenemedi',
|
||||||
|
tryAgain: 'Yeniden dene',
|
||||||
|
notAvailableTitle: 'Güncelleme kullanılamıyor',
|
||||||
|
unsupportedMessage: 'Bu AITURK sürümü uygulama içinden kendini güncelleyemiyor.',
|
||||||
|
connectionRetry: 'Bağlantınızı kontrol edip yeniden deneyin.',
|
||||||
|
latestBody: 'En güncel sürümü kullanıyorsunuz.',
|
||||||
|
latestBodyBackend: 'Sunucu en güncel sürümü kullanıyor.',
|
||||||
|
allSetTitle: 'Her şey hazır',
|
||||||
|
availableTitle: 'Yeni güncelleme var',
|
||||||
|
availableBody: 'AITURK’ün yeni sürümü kurulmaya hazır.',
|
||||||
|
availableTitleBackend: 'Sunucu güncellemesi var',
|
||||||
|
availableBodyBackend: 'Bağlı AITURK sunucusunun daha yeni sürümü kurulmaya hazır.',
|
||||||
|
availableBodyNoChangelog: 'Yeni sürüm hazır. Bu kurulum türü için sürüm notları bulunmuyor.',
|
||||||
|
updateNow: 'Şimdi güncelle',
|
||||||
|
maybeLater: 'Daha sonra',
|
||||||
|
moreChanges: count => `+ ${count} değişiklik daha.`,
|
||||||
|
manualTitle: 'Terminalden güncelleyin',
|
||||||
|
manualBody:
|
||||||
|
'AITURK’ü komut satırından kurduğunuz için güncellemeler de oradan yapılır. Şu komutu terminalinize yapıştırın:',
|
||||||
|
manualPickedUp: 'AITURK sonraki açılışta yeni sürümü kullanır.',
|
||||||
|
guiSkewTitle: 'Masaüstü uygulamasını güncelleyin',
|
||||||
|
guiSkewBody:
|
||||||
|
'Sunucu güncellendi ancak masaüstü uygulama paketi değişmedi. Sürümleri eşleştirmek için AITURK masaüstü paketinizi (AppImage / .deb / .rpm) güncelleyin veya yeniden kurun.',
|
||||||
|
copy: 'Kopyala',
|
||||||
|
copied: 'Kopyalandı',
|
||||||
|
done: 'Tamam',
|
||||||
|
applyingBody:
|
||||||
|
'AITURK güncelleyicisi ayrı bir pencerede çalışır ve bitince uygulamayı otomatik açar. Güncelleme sırasında AITURK’ü kendiniz yeniden açmayın.',
|
||||||
|
applyingBodyBackend:
|
||||||
|
'Uzak sunucu güncellemeyi uygulayıp yeniden başlayacak. Hazır olduğunda AITURK otomatik bağlanır.',
|
||||||
|
applyingClose: 'Güncelleme sırasında bu pencere kapanır; ardından AITURK kendiliğinden açılır.',
|
||||||
|
errorTitle: 'Güncelleme tamamlanamadı',
|
||||||
|
errorBody: 'Şimdi yeniden deneyebilirsiniz.',
|
||||||
|
blockerTitle: 'AITURK’ü güncellemek için yerel önizlemeler kapatılsın mı?',
|
||||||
|
blockerBody:
|
||||||
|
'AITURK güncellemeden önce bu yerel önizlemeleri durdurmalı. Bu işlem dosyalarınızı değiştirmez veya silmez.',
|
||||||
|
foreignBlockerTitle: 'AITURK’ü güncellemek için diğer işlemleri kapatın',
|
||||||
|
foreignBlockerBody:
|
||||||
|
'AITURK bu işlemleri otomatik olarak güvenle kapatamıyor. İlgili uygulamayı, terminali veya servisi kapatıp güncellemeyi yeniden deneyin.',
|
||||||
|
mixedBlockerBody:
|
||||||
|
'AITURK aşağıdaki yerel önizlemeleri kapatabilir. Güncellemenin sürmesi için diğer işlemleri kendiniz kapatmalısınız.',
|
||||||
|
closePreviewsAndUpdate: 'Önizlemeleri kapat ve güncelle',
|
||||||
|
closePreviewsAndCheckAgain: 'Önizlemeleri kapat ve yeniden denetle',
|
||||||
|
localPreview: 'Yerel önizleme',
|
||||||
|
portLabel: port => `Bağlantı noktası ${port}`,
|
||||||
|
pidLabel: pid => `İşlem kimliği ${pid}`,
|
||||||
|
technicalDetails: 'Teknik ayrıntılar',
|
||||||
|
notNow: 'Şimdi değil',
|
||||||
|
clientAlsoBehindTitle: 'Masaüstü uygulaması eski sürümde',
|
||||||
|
clientAlsoBehindMessage:
|
||||||
|
'Sunucu güncel ancak masaüstü uygulaması hâlâ eski sürümde. Son düzeltmeleri almak için uygulamayı güncelleyin.',
|
||||||
|
clientAlsoBehindAction: 'Masaüstü uygulamasını güncelle',
|
||||||
|
everythingDispatched: 'Güncelleme isteği gönderildi',
|
||||||
|
everythingSkipped: 'Atlandı',
|
||||||
|
everythingRowFailed: 'Güncelleme başarısız oldu',
|
||||||
|
everythingFanoutFailedTitle: 'Diğer kurulumlar güncellenemedi',
|
||||||
|
applyStatus: {
|
||||||
|
preparing: 'Sunucu güncelleniyor…',
|
||||||
|
pulling: 'Sunucu güncelleniyor…',
|
||||||
|
restarting: 'Güncellemeyi yüklemek için sunucu yeniden başlatılıyor…',
|
||||||
|
notAvailable: 'Bu sunucu için güncelleme kullanılamıyor.',
|
||||||
|
failed: 'Sunucu güncellemesi başarısız oldu.',
|
||||||
|
noReturn:
|
||||||
|
'Sunucu yeniden çevrimiçi olmadı. Güncelleme tamamlanmamış olabilir; sunucunun çalıştığı bilgisayarı kontrol edin.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TR_DESKTOP: Translations['desktop'] = {
|
||||||
|
audioReadFailed: 'Kaydedilen ses okunamadı',
|
||||||
|
sessionUnavailable: 'Oturum kullanılamıyor',
|
||||||
|
createSessionFailed: 'Yeni oturum oluşturulamadı',
|
||||||
|
promptFailed: 'İstem gönderilemedi',
|
||||||
|
providerCredentialRequired: 'İlk mesajınızı göndermeden önce sağlayıcı kimlik bilgisi ekleyin.',
|
||||||
|
emptySlashCommand: 'eğik çizgi komutu boş',
|
||||||
|
desktopCommands: 'Masaüstü komutları',
|
||||||
|
skillCommandsAvailable: count => `${count} beceri komutu kullanılabilir.`,
|
||||||
|
warningLine: message => `uyarı: ${message}`,
|
||||||
|
yoloArmed: 'Bu sohbet için YOLO etkinleştirildi',
|
||||||
|
yoloOff: 'YOLO kapalı',
|
||||||
|
yoloSystem: active => `Bu oturumda YOLO ${active ? 'açık' : 'kapalı'}`,
|
||||||
|
yoloTitle: 'YOLO',
|
||||||
|
yoloToggleFailed: 'YOLO durumu değiştirilemedi',
|
||||||
|
profileStatus: current =>
|
||||||
|
`Profil: ${current}. Başka profilde sohbet başlatmak için /profile <ad> komutunu veya “Yeni oturum” seçicisini kullanın.`,
|
||||||
|
unknownProfile: 'Bilinmeyen profil',
|
||||||
|
noProfileNamed: (target, available) => `“${target}” adlı profil yok. Kullanılabilir profiller: ${available}`,
|
||||||
|
newChatsProfile: name => `Yeni sohbetler ${name} profilini kullanacak.`,
|
||||||
|
setProfileFailed: 'Profil ayarlanamadı',
|
||||||
|
sttDisabled: 'Konuşmayı metne dönüştürme ayarlarda kapalı.',
|
||||||
|
stopFailed: 'Durdurulamadı',
|
||||||
|
regenerateFailed: 'Yeniden oluşturulamadı',
|
||||||
|
editFailed: 'Düzenlenemedi',
|
||||||
|
editTurnUnavailable: 'Bu tur artık sunucu geçmişinde yok; bağlam sıkıştırılırken kaldırılmış olabilir.',
|
||||||
|
resumeFailed: 'Sürdürülemedi',
|
||||||
|
readOnlyTranscriptTitle: 'Salt okunur açıldı',
|
||||||
|
readOnlyTranscriptBody:
|
||||||
|
'Bağlı sunuculardan hiçbiri bu eski sohbeti henüz sahiplenmediği için geçmiş salt okunur açıldı. Geçmiş korunuyor; bir sunucu sahiplenene kadar mesaj gönderilemez.',
|
||||||
|
readOnlyTranscriptSendBlocked: 'Bu sohbet salt okunur açıldı; mesaj gönderimi kapalı.',
|
||||||
|
resumeStrandedTitle: 'Oturum yüklenemedi',
|
||||||
|
resumeStrandedBody:
|
||||||
|
'Oturuma bağlanılamadı ve otomatik denemeler sona erdi. Ağ geçidinin çalıştığını kontrol edip yeniden deneyin.',
|
||||||
|
resumeRetry: 'Yeniden dene',
|
||||||
|
nothingToBranch: 'Dallanacak içerik yok',
|
||||||
|
branchNeedsChat: 'Dallanmadan önce bir sohbet başlatın veya sürdürün.',
|
||||||
|
sessionBusy: 'Oturum meşgul',
|
||||||
|
branchStopCurrent: 'Sohbeti dallandırmadan önce geçerli turu durdurun.',
|
||||||
|
branchNoText: 'Bu mesajda dallanma için metin yok.',
|
||||||
|
branchTitle: n => `Taslak: Dal #${n}`,
|
||||||
|
branchFailed: 'Dallanma başarısız oldu',
|
||||||
|
deleteFailed: 'Silinemedi',
|
||||||
|
archived: 'Arşivlendi',
|
||||||
|
archiveFailed: 'Arşivlenemedi',
|
||||||
|
cwdChangeFailed: 'Çalışma dizini değiştirilemedi',
|
||||||
|
cwdStagedTitle: 'Çalışma dizini değişikliği hazır',
|
||||||
|
cwdStagedMessage:
|
||||||
|
'Çalışma dizini değişikliğini bu etkin oturuma uygulamak için masaüstü sunucusunu yeniden başlatın.',
|
||||||
|
modelSwitchFailed: 'Model değiştirilemedi',
|
||||||
|
hydrationSyncing: profile => `${profile} eşitleniyor…`,
|
||||||
|
sessionExported: 'Oturum dışa aktarıldı',
|
||||||
|
sessionExportFailed: 'Oturum dışa aktarılamadı',
|
||||||
|
imageSaved: 'Görsel kaydedildi',
|
||||||
|
downloadStarted: 'İndirme başladı',
|
||||||
|
restartToUseSaveImage: 'Görsel kaydetmeyi kullanmak için AITURK IDE’yi yeniden başlatın.',
|
||||||
|
restartToSaveImages: 'Görselleri kaydetmek için AITURK IDE’yi yeniden başlatın',
|
||||||
|
imageDownloadFailed: 'Görsel indirilemedi',
|
||||||
|
openImage: 'Görseli aç',
|
||||||
|
downloadImage: 'Görseli indir',
|
||||||
|
savingImage: 'Görsel kaydediliyor',
|
||||||
|
imagePreviewFailed: 'Görsel önizlemesi açılamadı',
|
||||||
|
imageAttach: 'Görsel ekleme',
|
||||||
|
imageWriteFailed: 'Görsel diske yazılamadı.',
|
||||||
|
imageAttachFailed: 'Görsel eklenemedi',
|
||||||
|
attachImages: 'Görsel ekle',
|
||||||
|
clipboard: 'Pano',
|
||||||
|
noClipboardImage: 'Panoda görsel bulunamadı',
|
||||||
|
clipboardPasteFailed: 'Panodan yapıştırılamadı',
|
||||||
|
dropFiles: 'Dosyaları bırakın',
|
||||||
|
handoff: {
|
||||||
|
pickPlatform: 'Hedef seçin',
|
||||||
|
success: platform => `${platform} platformuna devredildi. İstediğiniz zaman buradan sürdürebilirsiniz.`,
|
||||||
|
systemNote: platform => `↻ ${platform} platformuna devredildi; istediğiniz zaman buradan sürdürün.`,
|
||||||
|
failed: error => `Devir başarısız oldu: ${error}`,
|
||||||
|
timedOut: 'Ağ geçidi beklenirken zaman aşımı oluştu. `hermes gateway` çalışıyor mu?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TR_TIPS: Translations['tips'] = {
|
||||||
|
close: 'Bu ipucunu bir daha gösterme',
|
||||||
|
items: {
|
||||||
|
'new-session': {
|
||||||
|
title: 'Yeni bir başlangıç',
|
||||||
|
text: 'Her yeni sohbetin kendi bağlamı, terminali ve çalışma dizini vardır.'
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
title: 'Bir kez öğretin',
|
||||||
|
text: 'Beceriler, AITURK’ün iş gerektirdiğinde yüklediği talimat klasörleridir.'
|
||||||
|
},
|
||||||
|
messaging: {
|
||||||
|
title: 'Masanızdan uzaktayken de AITURK',
|
||||||
|
text: 'Telegram, Discord, Slack ve diğer platformları bağlayın; aynı ajan ve bellekle çalışın.'
|
||||||
|
},
|
||||||
|
artifacts: {
|
||||||
|
title: 'AITURK’ün oluşturduğu her şey',
|
||||||
|
text: 'Tüm oturumların görselleri, dosyaları ve bağlantıları tek yerde listelenir.'
|
||||||
|
},
|
||||||
|
cron: {
|
||||||
|
title: 'Otomatik çalışan görevler',
|
||||||
|
text: 'Bir istemi saatlik, gecelik veya bir cron ifadesine göre zamanlayın.'
|
||||||
|
},
|
||||||
|
'command-palette': {
|
||||||
|
title: 'Her şeye tek yerden ulaşın',
|
||||||
|
text: 'Komut paletinden oturumlara, ayarlara, becerilere ve komutlara erişin.'
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
title: 'Profiller birbirinden bağımsızdır',
|
||||||
|
text: 'Her profilin kendi anahtarları, belleği ve oturumları vardır.'
|
||||||
|
},
|
||||||
|
'composer-mentions': {
|
||||||
|
title: 'Dosya ekleyin, komut çalıştırın',
|
||||||
|
text: 'Sohbete dosya eklemek için @, komut çalıştırmak için / yazın.'
|
||||||
|
},
|
||||||
|
'local-setup': {
|
||||||
|
title: 'Bu bilgisayar yerel model çalıştırabilir',
|
||||||
|
text: 'Donanımınız yerel bir modeli çalıştırabilir. Yerel model sohbetleri bilgisayarınızda kalır ve API kullanım ücreti oluşturmaz.',
|
||||||
|
action: 'Kurulumu yap'
|
||||||
|
},
|
||||||
|
'right-pane': {
|
||||||
|
title: 'Çalışma paneli',
|
||||||
|
text: 'Dosyalar, terminal, değişiklik incelemesi ve uygulama içi tarayıcı sağ paneli paylaşır.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||||
|
|
||||||
|
export const TR_FIELD_LABELS = defineFieldCopy({
|
||||||
|
model: 'Varsayılan model',
|
||||||
|
modelContextLength: 'Bağlam penceresi',
|
||||||
|
fallbackProviders: 'Yedek modeller',
|
||||||
|
toolsets: 'Etkin araç setleri',
|
||||||
|
timezone: 'Saat dilimi',
|
||||||
|
display: { personality: 'Asistan kişiliği', showReasoning: 'Düşünme bölümleri' },
|
||||||
|
desktop: {
|
||||||
|
repoScanEnabled: 'Git depolarını otomatik bul',
|
||||||
|
repoScanRoots: 'Depo arama klasörleri',
|
||||||
|
repoScanExcludePaths: 'Arama dışında bırakılan klasörler'
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
maxTurns: 'En fazla ajan adımı',
|
||||||
|
imageInputMode: 'Görsel ekleri',
|
||||||
|
apiMaxRetries: 'API yeniden deneme sayısı',
|
||||||
|
serviceTier: 'Hizmet düzeyi',
|
||||||
|
toolUseEnforcement: 'Araç kullanımı zorunluluğu'
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
cwd: 'Çalışma dizini',
|
||||||
|
backend: 'Komut çalıştırma ortamı',
|
||||||
|
timeout: 'Komut zaman aşımı',
|
||||||
|
persistentShell: 'Kalıcı kabuk oturumu',
|
||||||
|
envPassthrough: 'Aktarılacak ortam değişkenleri',
|
||||||
|
dockerImage: 'Docker imajı',
|
||||||
|
singularityImage: 'Singularity imajı',
|
||||||
|
modalImage: 'Modal imajı',
|
||||||
|
daytonaImage: 'Daytona imajı'
|
||||||
|
},
|
||||||
|
fileReadMaxChars: 'Dosya okuma sınırı',
|
||||||
|
toolOutput: {
|
||||||
|
maxBytes: 'Terminal çıktısı sınırı',
|
||||||
|
maxLines: 'Dosya sayfası satır sınırı',
|
||||||
|
maxLineLength: 'Satır uzunluğu sınırı'
|
||||||
|
},
|
||||||
|
codeExecution: { mode: 'Kod çalıştırma modu' },
|
||||||
|
approvals: { mode: 'Onay modu', timeout: 'Onay zaman aşımı', mcpReloadConfirm: 'MCP yeniden yüklemelerini onayla' },
|
||||||
|
commandAllowlist: 'İzin verilen komutlar',
|
||||||
|
security: { redactSecrets: 'Gizli bilgileri maskele', allowPrivateUrls: 'Özel ağ adreslerine izin ver' },
|
||||||
|
browser: {
|
||||||
|
allowPrivateUrls: 'Tarayıcıda özel ağ erişimi',
|
||||||
|
autoLocalForPrivateUrls: 'Özel ağ adreslerinde yerel tarayıcı',
|
||||||
|
useRealProfile: 'Gerçek tarayıcı profilimi kullan'
|
||||||
|
},
|
||||||
|
checkpoints: { enabled: 'Dosya geri yükleme noktaları', maxSnapshots: 'Geri yükleme noktası sınırı' },
|
||||||
|
voice: {
|
||||||
|
recordKey: 'Ses kaydı kısayolu',
|
||||||
|
maxRecordingSeconds: 'En uzun kayıt süresi',
|
||||||
|
autoTts: 'Yanıtları sesli oku'
|
||||||
|
},
|
||||||
|
stt: {
|
||||||
|
enabled: 'Konuşmayı yazıya çevir',
|
||||||
|
echoTranscripts: 'Ses dökümlerini sohbete ekle',
|
||||||
|
provider: 'Konuşmayı yazıya çevirme sağlayıcısı',
|
||||||
|
local: { model: 'Yerel ses dökümü modeli', language: 'Ses dökümü dili' },
|
||||||
|
openai: { model: 'OpenAI ses dökümü modeli' },
|
||||||
|
groq: { model: 'Groq ses dökümü modeli' },
|
||||||
|
mistral: { model: 'Mistral ses dökümü modeli' },
|
||||||
|
elevenlabs: {
|
||||||
|
modelId: 'ElevenLabs ses dökümü modeli',
|
||||||
|
languageCode: 'ElevenLabs dili',
|
||||||
|
tagAudioEvents: 'Ses olaylarını etiketle',
|
||||||
|
diarize: 'Konuşmacıları ayırt et'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tts: {
|
||||||
|
provider: 'Metni seslendirme sağlayıcısı',
|
||||||
|
edge: { voice: 'Edge sesi' },
|
||||||
|
openai: { model: 'OpenAI seslendirme modeli', voice: 'OpenAI sesi' },
|
||||||
|
elevenlabs: { voiceId: 'ElevenLabs sesi', modelId: 'ElevenLabs modeli' },
|
||||||
|
xai: {
|
||||||
|
voiceId: 'xAI (Grok) sesi',
|
||||||
|
language: 'xAI dili',
|
||||||
|
speed: 'xAI konuşma hızı',
|
||||||
|
autoSpeechTags: 'xAI otomatik ses etiketleri',
|
||||||
|
optimizeStreamingLatency: 'xAI akış gecikmesi iyileştirmesi',
|
||||||
|
sampleRate: 'xAI örnekleme hızı',
|
||||||
|
bitRate: 'xAI bit hızı'
|
||||||
|
},
|
||||||
|
minimax: { model: 'MiniMax seslendirme modeli', voiceId: 'MiniMax sesi' },
|
||||||
|
mistral: { model: 'Mistral seslendirme modeli', voiceId: 'Mistral sesi' },
|
||||||
|
gemini: { model: 'Gemini seslendirme modeli', voice: 'Gemini sesi' },
|
||||||
|
neutts: { model: 'NeuTTS modeli', device: 'NeuTTS işlem aygıtı' },
|
||||||
|
kittentts: { model: 'KittenTTS modeli', voice: 'KittenTTS sesi' },
|
||||||
|
piper: { voice: 'Piper sesi' },
|
||||||
|
deepinfra: { model: 'DeepInfra seslendirme modeli', voice: 'DeepInfra sesi' }
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
memoryEnabled: 'Kalıcı bellek',
|
||||||
|
userProfileEnabled: 'Kullanıcı profili',
|
||||||
|
memoryCharLimit: 'Bellek karakter sınırı',
|
||||||
|
userCharLimit: 'Profil karakter sınırı',
|
||||||
|
provider: 'Bellek sağlayıcısı'
|
||||||
|
},
|
||||||
|
context: { engine: 'Bağlam yönetimi' },
|
||||||
|
compression: {
|
||||||
|
enabled: 'Otomatik özetleme',
|
||||||
|
threshold: 'Özetleme eşiği',
|
||||||
|
targetRatio: 'Özetleme hedef oranı',
|
||||||
|
protectLastN: 'Korunacak son mesajlar'
|
||||||
|
},
|
||||||
|
delegation: {
|
||||||
|
model: 'Alt ajan modeli',
|
||||||
|
provider: 'Alt ajan sağlayıcısı',
|
||||||
|
maxIterations: 'Alt ajan adım sınırı',
|
||||||
|
maxConcurrentChildren: 'Eşzamanlı alt ajanlar',
|
||||||
|
childTimeoutSeconds: 'Alt ajan zaman aşımı',
|
||||||
|
reasoningEffort: 'Alt ajan düşünme düzeyi'
|
||||||
|
},
|
||||||
|
updates: { nonInteractiveLocalChanges: 'Güncellemede yerel değişiklikler' }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TR_FIELD_DESCRIPTIONS = defineFieldCopy({
|
||||||
|
model: 'Mesaj alanında başka bir model seçmediğiniz sürece yeni sohbetlerde kullanılır.',
|
||||||
|
modelContextLength: 'Seçilen modelin algılanan bağlam sınırını kullanmak için 0 bırakın.',
|
||||||
|
fallbackProviders: 'Varsayılan model başarısız olursa sırayla denenecek sağlayıcı ve model çiftleri.',
|
||||||
|
toolsets: 'Ajanın kullanabileceği araç gruplarını belirler.',
|
||||||
|
display: {
|
||||||
|
personality: 'Yeni oturumlarda kullanılacak varsayılan asistan üslubu.',
|
||||||
|
showReasoning: 'Sunucu sağladığında modelin düşünme bölümlerini gösterir.'
|
||||||
|
},
|
||||||
|
desktop: {
|
||||||
|
repoScanEnabled: 'Projeler bölümünde göstermek üzere yerel klasörlerde Git depoları arar.',
|
||||||
|
repoScanRoots: 'Aranacak klasörler. Boş bırakırsanız kullanıcı klasörünüz taranır.',
|
||||||
|
repoScanExcludePaths: 'Depo aranırken bu klasörler ve alt klasörleri atlanır.'
|
||||||
|
},
|
||||||
|
timezone: 'IANA saat dilimi kimliği; örneğin Europe/Istanbul. Boş bırakırsanız sistem saat dilimi kullanılır.',
|
||||||
|
browser: {
|
||||||
|
useRealProfile:
|
||||||
|
'Yerel taramada gerçek girişlerinizi kullanır. Varsayılan tarayıcınızın çerez, oturum ve tercihleri yönetilen bir kopyaya aktarılır; uygulamanın Chromium tarayıcısı bu kopyayı kullanır. Açık profiliniz doğrudan kullanılmaz ve kopya her çalıştırmada yenilenir. Bulut tarayıcısı seçiliyken de ajan yerel profil oturumu açabilir. Chrome, Edge, Brave ve Chromium desteklenir. Varsayılan olarak kapalıdır.',
|
||||||
|
allowPrivateUrls: 'Tarayıcının yerel ve özel ağ adreslerine erişip erişemeyeceğini belirler.',
|
||||||
|
autoLocalForPrivateUrls: 'Özel ağ adresleri için bu bilgisayardaki tarayıcıyı kullanır.'
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
imageInputMode: 'Görsel eklerinin modele nasıl gönderileceğini belirler.',
|
||||||
|
maxTurns: 'Bir çalışma durdurulmadan önce yapılabilecek en fazla araç çağrısı adımı.',
|
||||||
|
apiMaxRetries: 'Geçici API hatalarında yapılabilecek yeniden deneme sayısı.',
|
||||||
|
serviceTier: 'Sağlayıcının desteklediği hizmet düzeyi. Kullanılabilirlik ve ücret sağlayıcıya bağlıdır.',
|
||||||
|
toolUseEnforcement: 'Modelin araç kullanım kurallarına nasıl uyması gerektiğini belirler.'
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
cwd: 'Araç ve terminal işlemleri için varsayılan proje klasörü.',
|
||||||
|
backend: 'Komutların çalıştırılacağı yerel veya yalıtılmış ortam.',
|
||||||
|
timeout: 'Bir terminal komutunun tamamlanması için beklenecek en uzun süre.',
|
||||||
|
persistentShell: 'Desteklenen ortamlarda komutlar arasında kabuk durumunu korur.',
|
||||||
|
envPassthrough: 'Araç çalıştırma ortamına aktarılacak ortam değişkenleri.',
|
||||||
|
dockerImage: 'Docker ortamında kullanılacak konteyner imajı.',
|
||||||
|
singularityImage: 'Singularity ortamında kullanılacak imaj.',
|
||||||
|
modalImage: 'Modal ortamında kullanılacak imaj.',
|
||||||
|
daytonaImage: 'Daytona ortamında kullanılacak imaj.'
|
||||||
|
},
|
||||||
|
codeExecution: { mode: 'Kod çalıştırmanın mevcut projeyle ne ölçüde sınırlandırılacağını belirler.' },
|
||||||
|
fileReadMaxChars: 'Tek dosya okuma isteğinde alınabilecek en fazla karakter sayısı.',
|
||||||
|
toolOutput: {
|
||||||
|
maxBytes: 'Terminal çıktısında alınabilecek en fazla bayt sayısı.',
|
||||||
|
maxLines: 'Bir dosya sayfasında alınabilecek en fazla satır sayısı.',
|
||||||
|
maxLineLength: 'Tek çıktı satırında izin verilen en fazla uzunluk.'
|
||||||
|
},
|
||||||
|
approvals: {
|
||||||
|
mode: 'Açık onay gerektiren komutların nasıl ele alınacağını belirler.',
|
||||||
|
timeout: 'Onay isteminin zaman aşımına kadar bekleyeceği süre.',
|
||||||
|
mcpReloadConfirm: 'MCP sunucularını yeniden yüklemeden önce onay ister.'
|
||||||
|
},
|
||||||
|
commandAllowlist: 'Onay kuralları kapsamında izin verilecek komut kalıpları.',
|
||||||
|
security: {
|
||||||
|
redactSecrets: 'Algılanan gizli bilgileri mümkün olduğunda modelin görebildiği içerikten gizler.',
|
||||||
|
allowPrivateUrls: 'Araçların yerel ve özel ağ URL adreslerine erişimini belirler.'
|
||||||
|
},
|
||||||
|
checkpoints: {
|
||||||
|
enabled: 'Dosya düzenlemelerinden önce geri alınabilir kopyalar oluşturur.',
|
||||||
|
maxSnapshots: 'Saklanacak en fazla geri yükleme noktası sayısı.'
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
memoryEnabled: 'Gelecekteki oturumlara yardımcı olabilecek kalıcı bilgileri saklar.',
|
||||||
|
userProfileEnabled: 'Kullanıcı tercihlerinin kısa bir profilini tutar.',
|
||||||
|
memoryCharLimit: 'Kalıcı belleğe ayrılan en fazla karakter sayısı.',
|
||||||
|
userCharLimit: 'Kullanıcı profiline ayrılan en fazla karakter sayısı.',
|
||||||
|
provider: 'Kalıcı bilgilerin yönetileceği bellek hizmeti. Yerleşik belleği de kullanabilirsiniz.'
|
||||||
|
},
|
||||||
|
context: { engine: 'Bağlam sınırına yaklaşan uzun sohbetlerin nasıl yönetileceğini belirler.' },
|
||||||
|
compression: {
|
||||||
|
enabled: 'Sohbet uzadığında eski bağlamı özetler.',
|
||||||
|
threshold: 'Bağlam kullanımı bu eşiğe geldiğinde özetleme başlar.',
|
||||||
|
targetRatio: 'Özetlemeden sonra korunması hedeflenen bağlam oranı.',
|
||||||
|
protectLastN: 'Özetlenmeden korunacak son mesaj sayısı.'
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
autoTts: 'Asistan yanıtlarını otomatik olarak seslendirir.',
|
||||||
|
recordKey: 'Ses kaydını başlatmak için kullanılacak kısayol.',
|
||||||
|
maxRecordingSeconds: 'Bir ses kaydının saniye cinsinden en uzun süresi.'
|
||||||
|
},
|
||||||
|
tts: {
|
||||||
|
provider: 'Yazılı yanıtları sese dönüştürecek hizmet veya yerel model.',
|
||||||
|
xai: {
|
||||||
|
voiceId: 'xAI ses kimliği (örneğin eve) veya özel ses kimliği.',
|
||||||
|
language: 'Konuşma dili kodu (örneğin tr veya en); otomatik algılama için auto.',
|
||||||
|
speed: 'Konuşma hızı: 0.7 yavaş, 1.0 normal, 1.5 hızlı.',
|
||||||
|
autoSpeechTags: 'Seslendirmeden önce metne [laughing] veya [sighs] gibi ifade etiketleri ekletir.',
|
||||||
|
optimizeStreamingLatency: 'Kalite ve gecikme dengesi: 0 en yüksek kalite, 2 en düşük gecikme.',
|
||||||
|
sampleRate: 'Hertz cinsinden örnekleme hızı. Daha yüksek değer, daha yüksek kalite ve daha büyük dosya demektir.',
|
||||||
|
bitRate: 'Saniye başına bit cinsinden MP3 bit hızı. Yalnızca MP3 biçiminde geçerlidir.'
|
||||||
|
},
|
||||||
|
neutts: { device: 'NeuTTS modelinin çalıştırılacağı yerel işlem aygıtı.' }
|
||||||
|
},
|
||||||
|
stt: {
|
||||||
|
enabled: 'Yerel model veya sağlayıcı üzerinden konuşmayı yazıya çevirmeyi açar.',
|
||||||
|
echoTranscripts: 'Sesli mesajların ham 🎙️ dökümünü sohbete de gönderir.',
|
||||||
|
provider: 'Ses kayıtlarını yazıya çevirecek hizmet veya yerel model.',
|
||||||
|
local: { language: 'Ses dökümünde kullanılacak dil.', model: 'Bu bilgisayarda çalışacak ses dökümü modeli.' },
|
||||||
|
elevenlabs: {
|
||||||
|
languageCode: 'İsteğe bağlı ISO-639-3 dil kodu. Türkçe için tur; boş bırakırsanız dil otomatik algılanır.',
|
||||||
|
tagAudioEvents: 'Konuşma dışındaki ses olaylarını döküme etiket olarak ekler.',
|
||||||
|
diarize: 'Ses kaydındaki farklı konuşmacıları ayırt eder.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delegation: {
|
||||||
|
model: 'Alt ajanların varsayılan modeli.',
|
||||||
|
provider: 'Alt ajanların model isteklerini işleyecek sağlayıcı.',
|
||||||
|
maxIterations: 'Her alt ajanın yapabileceği en fazla adım.',
|
||||||
|
maxConcurrentChildren: 'Aynı anda çalışabilecek en fazla alt ajan sayısı.',
|
||||||
|
childTimeoutSeconds: 'Alt ajan çalışması için saniye cinsinden en uzun süre.',
|
||||||
|
reasoningEffort: 'Alt ajan modelinin düşünmeye ayıracağı çaba. Destek modele bağlıdır.'
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
nonInteractiveLocalChanges:
|
||||||
|
'Uygulama içinden güncellerken yerel kaynak değişiklikleri saklanır (stash) veya silinir (discard). Terminalden güncelleme her zaman sorar.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only display labels change; model IDs and serialized option values stay intact.
|
||||||
|
export const TR_FIELD_OPTIONS: Record<string, Record<string, string>> = {
|
||||||
|
'approvals.mode': { manual: 'Elle onayla', smart: 'Akıllı onay', off: 'Kapalı' },
|
||||||
|
'agent.image_input_mode': { auto: 'Otomatik', native: 'Doğrudan modele gönder', text: 'Metin olarak işle' },
|
||||||
|
'agent.service_tier': { auto: 'Otomatik', default: 'Varsayılan', flex: 'Esnek', priority: 'Öncelikli' },
|
||||||
|
'terminal.backend': {
|
||||||
|
local: 'Yerel bilgisayar',
|
||||||
|
docker: 'Docker',
|
||||||
|
singularity: 'Singularity',
|
||||||
|
modal: 'Modal',
|
||||||
|
daytona: 'Daytona',
|
||||||
|
ssh: 'SSH'
|
||||||
|
},
|
||||||
|
'code_execution.mode': { project: 'Proje kapsamında', strict: 'Sıkı yalıtım' },
|
||||||
|
'context.engine': { compressor: 'Özetleyici', default: 'Varsayılan', custom: 'Özel' },
|
||||||
|
'stt.provider': { local: 'Yerel model' },
|
||||||
|
'tts.neutts.device': { cpu: 'İşlemci (CPU)', cuda: 'NVIDIA ekran kartı (CUDA)', mps: 'Apple grafik işlemcisi (MPS)' },
|
||||||
|
'updates.non_interactive_local_changes': { stash: 'Değişiklikleri sakla', discard: 'Değişiklikleri sil' },
|
||||||
|
'delegation.reasoning_effort': {
|
||||||
|
none: 'Yok',
|
||||||
|
minimal: 'En az',
|
||||||
|
low: 'Düşük',
|
||||||
|
medium: 'Orta',
|
||||||
|
high: 'Yüksek',
|
||||||
|
xhigh: 'Çok yüksek',
|
||||||
|
max: 'En yüksek'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Translations } from './types'
|
||||||
|
|
||||||
|
export const TR_KEYBINDS: Translations['keybinds'] = {
|
||||||
|
title: 'Klavye kısayolları',
|
||||||
|
subtitle: open => `Değiştirmek için kısayola tıklayın. Bu paneli yeniden açmak için ${open}.`,
|
||||||
|
search: 'Kısayol ara…',
|
||||||
|
rebind: 'Değiştir',
|
||||||
|
reset: 'Varsayılana dön',
|
||||||
|
resetAll: 'Tümünü sıfırla',
|
||||||
|
pressKey: 'Bir tuşa basın…',
|
||||||
|
set: 'ayarla',
|
||||||
|
conflictWith: label => `“${label}” için de kullanılıyor`,
|
||||||
|
categories: {
|
||||||
|
composer: 'Mesaj yazma',
|
||||||
|
profiles: 'Profiller',
|
||||||
|
session: 'Oturum',
|
||||||
|
navigation: 'Gezinme',
|
||||||
|
view: 'Görünüm'
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
'keybinds.openPanel': 'Klavye kısayollarını aç',
|
||||||
|
'nav.commandPalette': 'Komut listesini aç',
|
||||||
|
'nav.commandCenter': 'Komut merkezini aç',
|
||||||
|
'nav.settings': 'Ayarları aç',
|
||||||
|
'nav.profiles': 'Profilleri aç',
|
||||||
|
'nav.skills': 'Yetenekleri aç',
|
||||||
|
'nav.messaging': 'Mesajlaşmayı aç',
|
||||||
|
'nav.artifacts': 'Çıktıları aç',
|
||||||
|
'nav.cron': 'Zamanlanmış işleri aç',
|
||||||
|
'nav.agents': 'Ajanları aç',
|
||||||
|
'session.new': 'Yeni oturum',
|
||||||
|
'session.newTab': 'Yeni oturum sekmesi',
|
||||||
|
'session.newWindow': 'Yeni pencere',
|
||||||
|
'session.next': 'Sonraki oturum',
|
||||||
|
'session.prev': 'Önceki oturum',
|
||||||
|
...Object.fromEntries(
|
||||||
|
Array.from({ length: 9 }, (_, index) => [`session.slot.${index + 1}`, `${index + 1}. son oturuma geç`])
|
||||||
|
),
|
||||||
|
'session.focusSearch': 'Oturumlarda ara',
|
||||||
|
'session.togglePin': 'Geçerli oturumu sabitle veya sabitlemeyi kaldır',
|
||||||
|
'session.archive': 'Geçerli oturumu arşivle',
|
||||||
|
'workspace.newWorktree': 'Yeni Git çalışma ağacı',
|
||||||
|
'workspace.openFolder': 'Klasörü proje olarak aç',
|
||||||
|
'composer.focus': 'Mesaj alanına odaklan',
|
||||||
|
'composer.modelPicker': 'Model seçiciyi aç',
|
||||||
|
'composer.voice': 'Sesli görüşmeyi başlat veya durdur',
|
||||||
|
'view.toggleSidebar': 'Oturum kenar çubuğunu göster veya gizle',
|
||||||
|
'view.toggleRightSidebar': 'Dosya gezginini göster veya gizle',
|
||||||
|
'view.toggleReview': 'İnceleme panelini göster veya gizle',
|
||||||
|
'view.toggleStatusbar': 'Durum çubuğunu göster veya gizle',
|
||||||
|
'view.toggleTabStrip': 'Sekmeleri göster veya gizle',
|
||||||
|
'view.showFiles': 'Dosya gezginini göster',
|
||||||
|
'view.showBrowser': 'Tarayıcıyı aç',
|
||||||
|
'view.toggleHud': 'Yüzen görünümü aç veya kapat',
|
||||||
|
'hud.snapToPointer': 'Yüzen görünümü fare işaretçisine taşı; açıkken tüm uygulamalarda geçerlidir',
|
||||||
|
'view.showTerminal': 'Terminali göster veya gizle',
|
||||||
|
'view.newTerminal': 'Yeni terminal',
|
||||||
|
'view.nextTerminal': 'Sonraki terminal',
|
||||||
|
'view.prevTerminal': 'Önceki terminal',
|
||||||
|
'view.closeTerminal': 'Terminali kapat',
|
||||||
|
'view.selectionToComposer': 'Seçimi mesaj alanına gönder',
|
||||||
|
'view.terminalCopy': 'Terminaldeki seçimi kopyala',
|
||||||
|
'view.terminalPaste': 'Terminale yapıştır',
|
||||||
|
'view.closeTab': 'Sekmeyi kapat',
|
||||||
|
'view.reopenTab': 'Kapatılan sekmeyi yeniden aç',
|
||||||
|
'view.flipPanes': 'Kenar çubuklarının yerini değiştir',
|
||||||
|
'view.findInPage': 'Sayfada bul',
|
||||||
|
'view.findNext': 'Sonraki eşleşmeyi bul',
|
||||||
|
'view.findPrevious': 'Önceki eşleşmeyi bul',
|
||||||
|
'appearance.toggleMode': 'Açık veya koyu görünüme geç',
|
||||||
|
'profile.default': 'Varsayılan profile geç',
|
||||||
|
...Object.fromEntries(
|
||||||
|
Array.from({ length: 18 }, (_, index) => [`profile.switch.${index + 1}`, `${index + 1}. profile geç`])
|
||||||
|
),
|
||||||
|
'profile.next': 'Sonraki profil',
|
||||||
|
'profile.prev': 'Önceki profil',
|
||||||
|
'profile.toggleAll': 'Tüm profiller görünümünü aç veya kapat',
|
||||||
|
'profile.create': 'Profil oluştur',
|
||||||
|
'composer.send': 'Mesajı gönder',
|
||||||
|
'composer.newline': 'Yeni satır ekle',
|
||||||
|
'composer.steer': 'Devam eden işi yönlendir',
|
||||||
|
'composer.queue': 'Mesajı kuyruğa ekle',
|
||||||
|
'composer.sendQueued': 'Sıradaki mesajı gönder',
|
||||||
|
'composer.mention': 'Dosya, klasör veya URL belirt',
|
||||||
|
'composer.slash': 'Eğik çizgi komut listesi',
|
||||||
|
'composer.help': 'Hızlı yardım',
|
||||||
|
'composer.history': 'Açılır liste veya geçmişte gezin',
|
||||||
|
'composer.cancel': 'Açılır listeyi kapat veya çalışmayı durdur'
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user