139 changed files with 7464 additions and 1131 deletions
+4 -1
View File
@@ -2,7 +2,7 @@
TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
- Ürün sürümü: **1.0.0-beta.1**, ilk paket hedefi Windows x64.
- Ürün sürümü: **1.0.0-beta.4**, ilk paket hedefi Windows x64.
- Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir.
- İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir.
Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner.
@@ -10,6 +10,9 @@ TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
- TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının
kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir.
Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz.
- AITURK model kataloğu arka planda otomatik yenilenir. Açık model listeleri
dakikada bir sunucuyu kontrol eder; 9Router ve OmniRoute'ta yeni yayımlanan
kayıtlar elle liste düzenlemeden görünür. Admin engeli sunucuda uygulanır.
- Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda
çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir.
@@ -0,0 +1,12 @@
/* macOS displays these explanations when the user grants an OS permission. */
"CFBundleDisplayName" = "AITURK IDE";
"NSAudioCaptureUsageDescription" = "AITURK IDE, sesli konuşmalar için ses yakalamayı kullanır.";
"NSCameraUsageDescription" = "AITURK IDE, etkinleştirdiğiniz bir eklenti veya özellik istediğinde kamerayı kullanır.";
"NSMicrophoneUsageDescription" = "AITURK IDE, sesli giriş ve sesli konuşmalar için mikrofonu kullanır.";
"NSCalendarsUsageDescription" = "AITURK IDE, istediğiniz toplantı ve zamanlama işlemlerini yapmak için Takvime erişir.";
"NSCalendarsFullAccessUsageDescription" = "AITURK IDE, açıkça istediğinizde etkinlikleri okumak ve yönetmek için Takvime tam erişim ister.";
"NSRemindersUsageDescription" = "AITURK IDE, istediğiniz kişisel asistan ve zamanlama işlemlerini yapmak için Anımsatıcılara erişir.";
"NSRemindersFullAccessUsageDescription" = "AITURK IDE, açıkça istediğinizde anımsatıcıları okumak ve yönetmek için Anımsatıcılara 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.
+44
View File
@@ -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.
+18
View File
@@ -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.
+14 -8
View File
@@ -27,12 +27,15 @@ import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { resolveElectronBinary } from './electron-binary'
import { startMockServer, type MockServerOptions } from './mock-server'
import { type MockServerOptions, startMockServer } from './mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
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) ──────────────────────
@@ -259,6 +262,8 @@ export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}
return {
...clean,
// AITURK ignores inherited Hermes state; use the same explicit sandbox.
AITURK_IDE_HOME: sandbox.hermesHome,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
@@ -366,6 +371,7 @@ export interface MockBackendFixture {
}
export interface MockBackendOptions {
mockServer?: MockServerOptions
/**
* Optional YAML lines to inject under the `display:` section of the
* generated config.yaml. Used by the interim-message e2e test to toggle
@@ -385,10 +391,6 @@ export interface MockBackendOptions {
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export interface MockBackendOptions {
mockServer?: MockServerOptions
}
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer(options.mockServer)
@@ -520,16 +522,19 @@ providers:
*/
function resolvePackagedBinaryPath(): string {
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') {
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()
@@ -643,6 +648,7 @@ export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFi
// `position: fixed; inset: 0`. If the hit element or an ancestor
// is a full-viewport fixed overlay, we're still covered.
let node: Element | null = el
while (node) {
const cs = window.getComputedStyle(node)
+2 -2
View File
@@ -34,9 +34,9 @@ test.afterAll(async () => {
fixture = null
})
test('window opens with the Hermes title', async () => {
test('window opens with the AITURK title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
expect(title).toContain('AITURK')
})
test('renderer loads and shows DOM content', async () => {
+136
View File
@@ -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
})
}
})
@@ -2,13 +2,23 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
import { classifyActiveRuntime, hasValidBootstrapMarker, needsPackagedRuntimeUpgrade } from './active-runtime-state'
const VALID_MARKER = {
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
schemaVersion: 1
}
test('a package upgrade refreshes only a proven managed runtime, once per package', () => {
const stamp = {commit: 'b'.repeat(40), source: 'git'}
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, VALID_MARKER), true)
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, {...VALID_MARKER, pinnedCommit: stamp.commit}), false)
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, {...VALID_MARKER, pinnedCommit: 'c'.repeat(40), packageCommit: stamp.commit}), false)
assert.equal(needsPackagedRuntimeUpgrade(false, stamp, VALID_MARKER), false)
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, null), false)
assert.equal(needsPackagedRuntimeUpgrade(true, {commit: '0'.repeat(40), source: 'fallback'}, VALID_MARKER), false)
})
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
})
@@ -1,8 +1,21 @@
export interface BootstrapMarkerLike {
pinnedCommit?: unknown
packageCommit?: unknown
schemaVersion?: unknown
}
// An AITURK package upgrade must bring its managed agent along with the UI.
// Developer checkouts and installs without proven Desktop ownership keep their
// existing launch behavior. The installer preserves edits and refuses rollback.
export function needsPackagedRuntimeUpgrade(
packaged: boolean, stamp: { commit?: string; source?: string } | null,
marker: BootstrapMarkerLike | null
): boolean {
return Boolean(packaged && stamp?.source !== 'fallback' &&
/^[0-9a-f]{40}$/i.test(stamp?.commit || '') && !/^0+$/.test(stamp?.commit || '') &&
hasValidBootstrapMarker(marker, 1) && (marker?.packageCommit || marker?.pinnedCommit) !== stamp?.commit)
}
export interface ActiveRuntimeState {
hasValidMarker: boolean
shouldUseActiveRuntime: boolean
@@ -143,8 +143,8 @@ test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', ()
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
resolveHead: () => realHead
}),
'd'.repeat(40),
'packaged real pin wins over checkout HEAD'
realHead,
'marker reports the actual installed checkout, including a preserved newer runtime'
)
assert.equal(
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
+7 -6
View File
@@ -111,16 +111,16 @@ function resolveMarkerPinnedCommit(
): string | null {
const resolveHead = opts.resolveHead || resolveCheckoutHead
if (installStamp && isPinnedCommit(installStamp.commit)) {
return installStamp.commit
}
const head = resolveHead(activeRoot)
if (head) {
return head
}
if (installStamp && isPinnedCommit(installStamp.commit)) {
return installStamp.commit
}
return readExistingPinnedCommit(activeRoot)
}
@@ -918,14 +918,14 @@ async function runBootstrap(opts) {
try {
const existingCheckout = hasExistingGitCheckout(activeRoot)
const pinCommit = !existingCheckout
const pinCommit = Boolean(installStamp && isPinnedCommit(installStamp.commit)) || !existingCheckout
if (existingCheckout && installStamp && installStamp.commit) {
emit({
type: 'log',
line:
`[bootstrap] existing checkout detected at ${activeRoot}; ` +
`not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}`
`applying packaged install stamp ${installStamp.commit.slice(0, 12)} with installer rollback protection`
})
}
@@ -1002,6 +1002,7 @@ async function runBootstrap(opts) {
const markerPayload = {
pinnedCommit,
packageCommit: installStamp && isPinnedCommit(installStamp.commit) ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null
}
+48 -23
View File
@@ -30,7 +30,7 @@ import {
systemPreferences
} from 'electron'
import { classifyActiveRuntime } from './active-runtime-state'
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
import { appIconCandidates, resolveAppIcon } from './app-icon'
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
@@ -261,6 +261,8 @@ import {
resolveOauthRestAuth,
resolveReadinessProbeAuth
} from './native-auth-decisions'
import { nativeDialogCopy } from './native-dialog-copy'
import { localizeNativeMenu, type NativeMenuLocale, resolveNativeMenuLocale } from './native-menu-locale'
import {
nativeRefreshUrl,
type NativeTokenSet,
@@ -2312,8 +2314,8 @@ async function waitForUpdateToFinish() {
rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`)
dialog.showMessageBox({
type: 'warning',
title: 'Hermes update',
message: 'The update finished, but needs one more step',
title: nativeText().update,
message: nativeText().updateMore,
detail: result.message
})
} else if (result && result.ok) {
@@ -2321,8 +2323,8 @@ async function waitForUpdateToFinish() {
} else if (result) {
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
dialog.showErrorBox(
'Hermes update did not finish',
`${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}`
nativeText().updateFailed,
`${result.message}\n\n${nativeText().details}: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}`
)
}
} catch (err) {
@@ -4597,6 +4599,7 @@ function writeBootstrapMarker(payload) {
const merged = {
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
pinnedCommit: payload.pinnedCommit || null,
packageCommit: payload.packageCommit || null,
pinnedBranch: payload.pinnedBranch || null,
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
@@ -4905,6 +4908,11 @@ function resolveHermesBackend(backendArgs) {
// bootstrap when the runtime itself is unusable.
const activeRuntime = activeRuntimeState()
if (needsPackagedRuntimeUpgrade(IS_PACKAGED, INSTALL_STAMP, readBootstrapMarker())) {
rememberLog('[bootstrap] AITURK package changed; updating its managed agent before launch.')
return createBootstrapBackend(backendArgs)
}
if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) {
if (!activeRuntime.hasValidMarker) {
rememberLog(
@@ -6080,11 +6088,11 @@ async function saveImageFromUrl(rawUrl) {
}
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Save Image',
title: nativeText().saveImage,
defaultPath: downloadsDir ? path.join(downloadsDir, fallbackName) : fallbackName,
filters: [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: 'All Files', extensions: ['*'] }
{ name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: nativeText().allFiles, extensions: ['*'] }
]
})
@@ -6749,11 +6757,14 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow)
webContents.send('hermes:window-state-changed', state)
}
let nativeUiLocale: NativeMenuLocale = 'tr'
const nativeText = () => nativeDialogCopy(nativeUiLocale)
function buildApplicationMenu() {
const template = []
const checkForUpdatesItem = {
label: 'AITURK IDE güncellemeleri…',
label: 'Check for Updates…',
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
}
@@ -6880,7 +6891,7 @@ function buildApplicationMenu() {
submenu: [checkForUpdatesItem]
})
return Menu.buildFromTemplate(template)
return Menu.buildFromTemplate(localizeNativeMenu(template, nativeUiLocale, APP_NAME))
}
function toggleDevTools(window) {
@@ -7170,13 +7181,13 @@ function installDownloadHandling() {
try {
item.setSaveDialogOptions({
title: 'Save File',
title: nativeText().saveFile,
defaultPath: path.join(app.getPath('downloads'), filename),
filters:
extension || /^image\//i.test(item.getMimeType() || '')
? [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: 'All Files', extensions: ['*'] }
{ name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: nativeText().allFiles, extensions: ['*'] }
]
: undefined
})
@@ -7529,7 +7540,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
win = new BrowserWindow({
width: 520,
height: 720,
title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway',
title: silent ? nativeText().cloudConnect : nativeText().gatewaySignIn,
autoHideMenuBar: true,
// Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in
// well under a second, so the window normally never needs to show. We
@@ -7958,7 +7969,7 @@ async function finalizeGatewayDownload(res, statusCode, headers, ctx: any = {})
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: filename,
title: 'Save File'
title: nativeText().saveFile
})
if (result.canceled || !result.filePath) {
@@ -8115,7 +8126,7 @@ async function saveGatewayFileViaDataUrl(
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: filename,
title: 'Save File'
title: nativeText().saveFile
})
if (result.canceled || !result.filePath) {
@@ -8407,7 +8418,7 @@ function renewPortalAccessSilently() {
width: 520,
height: 720,
show: false,
title: 'Renewing Hermes Cloud session…',
title: nativeText().cloudRenew,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
@@ -8512,7 +8523,7 @@ function openPortalLoginWindow() {
win = new BrowserWindow({
width: 520,
height: 720,
title: 'Sign in to Hermes Cloud',
title: nativeText().cloudSignIn,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
@@ -16758,7 +16769,7 @@ ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => {
}
const result = await dialog.showOpenDialog(mainWindow, {
title: options?.title || 'Add context',
title: options?.title || nativeText().addContext,
defaultPath: resolvedDefaultPath,
properties: properties as any,
filters: Array.isArray(options?.filters) ? options.filters : undefined
@@ -16781,7 +16792,7 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
// elsewhere (the backend, for profile archives); this only picks the path.
ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => {
const result = await dialog.showSaveDialog(mainWindow, {
title: options?.title || 'Save',
title: options?.title || nativeText().save,
defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined,
filters: Array.isArray(options?.filters) ? options.filters : undefined
})
@@ -16949,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).
ipcMain.on('hermes:native-theme', (_event, mode) => {
if (!THEME_SOURCES.has(mode)) {
@@ -17281,7 +17306,7 @@ ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
const result = await dialog.showOpenDialog({
title: 'Choose default project directory',
title: nativeText().chooseProjectDirectory,
properties: ['openDirectory', 'createDirectory'],
defaultPath: readDefaultProjectDir() || app.getPath('home')
})
@@ -17841,7 +17866,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
return false
}
const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff)
const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff, nativeUiLocale)
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
if (!prompt || !parent || parent.isDestroyed()) {
@@ -17853,7 +17878,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
void dialog
.showMessageBox(parent, {
buttons: ['Keep Running', 'Quit Anyway'],
buttons: [nativeText().keepRunning, nativeText().quitAnyway],
cancelId: 0,
defaultId: 0,
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) })
}
})
}
+1
View File
@@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
contextBridge.exposeInMainWorld('hermesDesktop', {
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
glassSupported: translucencySupport?.glass === true,
translucencySupported: translucencySupport?.translucency === true,
// Launch-flag fact: the app was started with --local, so the renderer may
+17 -3
View File
@@ -38,7 +38,7 @@ test('quitPromptFor names the running chats', () => {
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
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('• 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)
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('• e'))
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)
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'))
})
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)
})
+8 -4
View File
@@ -65,7 +65,7 @@ export interface QuitPrompt {
* 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.
*/
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) {
return null
}
@@ -75,18 +75,22 @@ export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): nu
const lines = listed.map(title => `${title}`)
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 {
detail: [
lines.join('\n'),
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)
.join('\n')
.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.`
}
}
+9 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide",
"productName": "AITURK IDE",
"private": true,
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.6",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis",
"repository": {
@@ -35,6 +35,8 @@
"pack": "npm run build && npm run builder -- --dir --publish never",
"dist": "npm run build && npm run builder",
"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:zip": "npm run build && npm run builder -- --mac zip",
"dist:win": "npm run build && npm run builder -- --win",
@@ -229,6 +231,12 @@
"dist/**"
],
"mac": {
"extraResources": [
{
"from": "assets/mac/tr.lproj/InfoPlist.strings",
"to": "tr.lproj/InfoPlist.strings"
}
],
"category": "public.app-category.developer-tools",
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
+23 -20
View File
@@ -19,10 +19,10 @@ const PLATFORM = process.platform
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
const APP = (() => {
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 {
appPath,
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'),
binary: path.join(appPath, 'Contents', 'MacOS', PACKAGE_JSON.build.executableName),
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
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')
return {
appPath: unpacked,
binary: path.join(unpacked, 'Hermes.exe'),
binary: path.join(unpacked, `${PACKAGE_JSON.build.executableName}.exe`),
resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'),
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')
return {
appPath: unpacked,
binary: path.join(unpacked, 'Hermes'),
binary: path.join(unpacked, PACKAGE_JSON.build.executableName),
resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
}
})()
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
// HERMES_HOME and never touches this.
const DEFAULT_HERMES_HOME = (() => {
// Match AITURK's product-owned runtime; never inspect another Hermes install.
const DEFAULT_AGENT_HOME = (() => {
if (process.env.AITURK_IDE_HOME) return path.resolve(process.env.AITURK_IDE_HOME)
const userData = process.env.AITURK_IDE_USER_DATA_DIR || process.env.HERMES_DESKTOP_USER_DATA_DIR
if (userData) return path.join(path.resolve(userData), 'agent-home')
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')
function die(message) {
@@ -124,10 +124,10 @@ function ensurePackagedApp() {
function resolveDmgPath() {
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
.readdirSync(RELEASE_ROOT)
.filter(name => name.endsWith('.dmg'))
@@ -141,15 +141,15 @@ function resolveDmgPath() {
return candidates.length > 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() {
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
// Select only this AITURK release and architecture.
if (!exists(RELEASE_ROOT)) return null
const candidates = fs
.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) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).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_USER_DATA_DIR = userDataDir
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_ROOT
@@ -275,7 +278,7 @@ function launchFresh() {
console.log('\nFresh install sandbox:')
console.log(` root: ${sandbox}`)
console.log(` electron userData: ${userDataDir}`)
console.log(` HERMES_HOME: ${hermesHome}`)
console.log(` AITURK_IDE_HOME: ${hermesHome}`)
console.log(` cwd: ${cwd}`)
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
@@ -399,8 +402,8 @@ function printArtifacts(options = {}) {
function help() {
console.log(`Usage:
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
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 + AITURK_IDE_HOME
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:all # build installer, validate app payload, print paths
@@ -245,7 +245,7 @@ export function VoicePlaybackActivity() {
variant="ghost"
>
<VolumeX className={iconSize.xs} />
Stop
{t.surfaces.stop}
</Button>
</div>
)
+16 -6
View File
@@ -11,6 +11,7 @@ import { lazy, type ReactNode, Suspense } from 'react'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
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 }))
// Built-in page views + their pane titles, keyed by route.
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = {
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' },
[MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' },
[SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' }
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; titleKey: keyof Translations['sidebar']['nav'] }> = {
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, titleKey: 'artifacts' },
[MESSAGING_ROUTE]: { render: () => <MessagingView />, titleKey: 'messaging' },
[SKILLS_ROUTE]: { render: () => <SkillsView />, titleKey: 'skills' }
}
/** 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. */
function routeTitle(path: string): string {
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)
}
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 }) {
const { t } = useI18n()
const builtin = BUILTIN_PAGES[path]
// Subscribe so a plugin page tile appears the moment its route registers.
@@ -74,7 +83,7 @@ function RouteTilePane({ path }: { path: string }) {
return (
<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>
)
}
@@ -91,6 +100,7 @@ export const watchRouteTiles = paneMirror<RouteTile>({
dir: t => t.dir,
minWidth: '22rem',
title: routeTitle,
tabTitle: path => <RouteTileTitle path={path} />,
render: path => <RouteTilePane path={path} />,
close: closeRouteTile
})
+3 -2
View File
@@ -333,6 +333,7 @@ function TileChat({
}
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
const { t } = useI18n()
const tiles = useStore($sessionTiles)
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
const ownerRoute = tile?.ownerRoute
@@ -462,10 +463,10 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
return (
<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="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>
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
Retry
{t.common.retry}
</Button>
</div>
</div>
@@ -72,47 +72,6 @@ interface Option<T extends string = 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 }) {
if (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
}
/** 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. */
const keepOpen = (event: Event) => event.preventDefault()
@@ -151,6 +110,48 @@ function OptionRadio({ option }: { option: Option }) {
export function SidebarFilterMenu({ className }: { className?: string }) {
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 ordering = useStore($sidebarOrdering)
const rowMeta = useStore($sidebarRowMeta)
@@ -170,7 +171,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
const projects = useStore($projectTree)
const hasCost = useStore($sessionsHaveCost)
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
// at module load: switching to a remote profile swaps the bridge underneath.
const prAvailable = Boolean(desktopGit()?.review?.prList)
@@ -217,12 +218,12 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Filters"
aria-label={t.surfaces.filters.filters}
className={cn(
className,
'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
// 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.
filtersActive && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
@@ -238,7 +239,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger hideChevron>
Grouping
{t.surfaces.filters.grouping}
<span className="ml-auto flex items-center gap-1 pl-4 text-(--ui-text-tertiary)">
{groupingLabel}
<Codicon name="chevron-right" size="1rem" />
@@ -257,7 +258,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Ordering</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger>{t.surfaces.filters.ordering}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
onValueChange={value => setSidebarOrdering(value as SidebarOrdering)}
@@ -271,7 +272,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Show</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger>{t.surfaces.filters.show}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{rowMetaOptions.map(option => (
<OptionCheckbox
@@ -284,22 +285,22 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* A render variant, not a grouping: three-line cards (project · age /
title / model · size) compose with whichever grouping is active. */}
{/* A render variant, not a grouping: three-line cards (project · age /
title / model · size) compose with whichever grouping is active. */}
<OptionCheckbox
checked={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>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Filters</DropdownMenuLabel>
<DropdownMenuLabel>{t.surfaces.filters.filters}</DropdownMenuLabel>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger>{t.surfaces.filters.status}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{STATUS_FILTERS.map(option => (
<OptionCheckbox
@@ -316,7 +317,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
this submenu never appears rather than filtering everything out. */}
{prAvailable && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Pull request</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger>{t.surfaces.filters.pullRequest}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{PR_FILTERS.map(option => (
<OptionCheckbox
@@ -331,7 +332,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>Profile</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger>{t.surfaces.filters.profile}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-80 overflow-y-auto">
{/* Scoped to one profile the rail is already the filter, so the
per-profile boxes only appear where they can narrow something.
@@ -358,7 +359,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
{projects.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Project</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger>{t.surfaces.filters.project}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-80 overflow-y-auto">
{projects.map(project => (
<OptionCheckbox
@@ -378,7 +379,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
)}
{/* 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
profile would strand the sidebar in a mode nothing can leave (the
rail hides its switcher at one profile too). */}
@@ -393,23 +394,25 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
<OptionCheckbox
checked={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
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>
<DropdownMenuSeparator />
{foldIds.length > 0 && (
<DropdownMenuItem onSelect={() => setWorkspaceNodesOpen(foldIds, foldCollapsed)}>
{foldCollapsed ? 'Expand all' : 'Collapse all'}
{foldCollapsed ? t.surfaces.filters.expand : t.surfaces.filters.collapse}
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={unreadIds.length === 0} onSelect={markAllSessionsRead}>
Mark all as read
{t.surfaces.filters.markRead}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,8 +1,14 @@
import { useEffect, useRef } from 'react'
import { useI18n } from '@/i18n'
import { setPetActivity } from '@/store/pet'
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 { $attentionSessionIds } from '@/store/session-states'
import { isAuxiliaryWindow } from '@/store/windows'
@@ -23,6 +29,12 @@ interface PetBridgeParams {
* window that can drop a submit. Primary window only.
*/
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
const { locale } = useI18n()
useEffect(() => {
if (!isAuxiliaryWindow()) {
setPetOverlayLocale(locale)
}
}, [locale])
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const resumeSessionRef = useRef(resumeSession)
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { useI18n } from '@/i18n'
import {
initQuickEntryBridge,
QUICK_TARGET_CURRENT,
@@ -51,6 +52,7 @@ function sessionOptions(): QuickEntrySessionOption[] {
* one keystroke would send N prompts.
*/
export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: QuickEntryBridgeParams): void {
const { locale } = useI18n()
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const startFreshRef = useRef(startFreshSessionDraft)
@@ -112,7 +114,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
}
const push = () => {
api.pushState({ connected: $gatewayState.get() === 'open', sessions: sessionOptions() })
api.pushState({ connected: $gatewayState.get() === 'open', locale, sessions: sessionOptions() })
}
push()
@@ -124,5 +126,5 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
offGateway()
offSessions()
}
}, [])
}, [locale])
}
+4 -1
View File
@@ -22,6 +22,7 @@ import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { getLogs } from '@/hermes'
import { useI18n } from '@/i18n'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { openPreview } from '@/store/preview'
@@ -34,6 +35,8 @@ import { $currentCwd } from '@/store/session'
// ---------------------------------------------------------------------------
export function LogsPane() {
const { t } = useI18n()
const { data, error } = useQuery({
queryKey: ['contrib-logs-tail'],
queryFn: () => getLogs({ lines: 300 }),
@@ -41,7 +44,7 @@ export function LogsPane() {
})
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) {
@@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
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
// up (in-app) plus where to grab a key.
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
const { t } = useI18n()
return (
<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">
<PawPrint className="size-5" />
</span>
<div className="space-y-1.5">
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">
Add an image backend to generate
</p>
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">{t.surfaces.imageBackendTitle}</p>
<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>
</div>
<Button onClick={onSetup} size="sm">
<Settings2 className="size-4" />
Set up image generation
{t.surfaces.setupImage}
</Button>
<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}>
Nous Portal
</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>
<button
aria-label="Remove reference"
aria-label={t.surfaces.removeReference}
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
onClick={onRemove}
type="button"
@@ -255,7 +255,7 @@ export function PetGenerateContent() {
type="button"
>
<ImageIcon className="size-3" />
Add a reference
{t.surfaces.addReference}
</button>
)}
</div>
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ErrorBoundary } from '@/components/error-boundary'
import { I18nProvider } from '@/i18n'
import { ThemeProvider } from '@/themes/context'
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
* 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
* in normal windows; the overlay must be see-through, so we force every host
@@ -30,7 +31,9 @@ export function mountPetOverlay(): void {
<StrictMode>
<ErrorBoundary label="pet-overlay">
<ThemeProvider>
<I18nProvider configClient={null}>
<PetOverlayApp />
</I18nProvider>
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
@@ -5,6 +5,7 @@ import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts'
import { PetBubble } from '@/components/pet/pet-bubble'
import { PetSprite } from '@/components/pet/pet-sprite'
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
import { normalizeLocale, useI18n } from '@/i18n'
import { Mail } from '@/lib/icons'
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
import { overlayWindowSize } from '@/store/pet-overlay'
@@ -61,6 +62,7 @@ interface DragState {
}
export function PetOverlayApp() {
const { t, setLocale } = useI18n()
const info = useStore($petInfo)
const [composerOpen, setComposerOpen] = useState(false)
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)
useEffect(() => {
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
void setLocale(normalizeLocale(payload.locale))
setPetInfo(payload.info)
$petActivity.set(payload.activity ?? {})
setBusy(Boolean(payload.busy))
@@ -115,7 +118,7 @@ export function PetOverlayApp() {
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
return off
}, [])
}, [setLocale])
// Click-through: make only the *solid* sprite pixels (plus the bubble / mail
// button / open composer) interactive — clicks on the transparent rectangle
@@ -397,7 +400,7 @@ export function PetOverlayApp() {
setComposerOpen(false)
}
}}
placeholder="Message…"
placeholder={t.surfaces.pet.message}
ref={inputRef}
style={{
background: 'var(--ui-bg-elevated)',
@@ -447,7 +450,7 @@ export function PetOverlayApp() {
stopPropagation keeps a click from starting a window drag. */}
{unread && (
<button
aria-label="Open in Hermes"
aria-label={t.surfaces.pet.openApp}
onClick={openApp}
onPointerDown={e => e.stopPropagation()}
onPointerUp={e => e.stopPropagation()}
@@ -468,7 +471,7 @@ export function PetOverlayApp() {
top: 0,
width: 24
}}
title="Open in Hermes"
title={t.surfaces.pet.openApp}
type="button"
>
<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: 'Customers 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('AITURKe sor…')
expect((screen.getByRole('combobox', { name: 'Hedef oturum' }) as HTMLSelectElement).value).toBe(
'stored-session-42'
)
expect(screen.getByRole('option', { name: 'Customers 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 { normalizeLocale, useI18n } from '@/i18n'
import {
initialQuickComposerState,
QUICK_TARGET_CURRENT,
@@ -26,6 +27,8 @@ import {
* the primary renderer's normal prompt-submit path.
*/
export function QuickEntryApp() {
const { t, setLocale } = useI18n()
const copy = t.surfaces.quickEntry
const inputRef = useRef<HTMLInputElement>(null)
// The reducer returns { send, state }; this wrapper performs the side effect
@@ -56,6 +59,7 @@ export function QuickEntryApp() {
})
const offState = api?.onState(payload => {
void setLocale(normalizeLocale(payload?.locale))
dispatch({
connected: payload?.connected === true,
sessions: Array.isArray(payload?.sessions) ? payload.sessions : [],
@@ -69,7 +73,7 @@ export function QuickEntryApp() {
offShown?.()
offState?.()
}
}, [])
}, [setLocale])
return (
<div
@@ -110,7 +114,7 @@ export function QuickEntryApp() {
</span>
<input
aria-label="Quick Entry"
aria-label={copy.title}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
@@ -131,7 +135,7 @@ export function QuickEntryApp() {
dispatch({ type: 'dismiss' })
}
}}
placeholder={state.connected ? 'Ask Hermes…' : 'Not connected — open Hermes to reconnect'}
placeholder={state.connected ? copy.ask : copy.disconnected}
ref={inputRef}
spellCheck={false}
style={{
@@ -158,10 +162,10 @@ export function QuickEntryApp() {
userSelect: 'none'
}}
>
Send to
{copy.sendTo}
</label>
<select
aria-label="Target session"
aria-label={copy.target}
disabled={!state.connected}
id="quick-entry-target"
onChange={event => dispatch({ target: event.target.value, type: 'target' })}
@@ -182,8 +186,8 @@ export function QuickEntryApp() {
}}
value={state.target}
>
<option value={QUICK_TARGET_CURRENT}>Current chat</option>
<option value={QUICK_TARGET_NEW}>New session</option>
<option value={QUICK_TARGET_CURRENT}>{copy.current}</option>
<option value={QUICK_TARGET_NEW}>{copy.newSession}</option>
{state.sessions.map(session => (
<option key={session.id} value={session.id}>
{session.title}
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ErrorBoundary } from '@/components/error-boundary'
import { I18nProvider } from '@/i18n'
import { ThemeProvider } from '@/themes/context'
import { QuickEntryApp } from './quick-entry-app'
@@ -30,7 +31,9 @@ export function mountQuickEntry(): void {
<StrictMode>
<ErrorBoundary label="quick-entry">
<ThemeProvider>
<I18nProvider configClient={null}>
<QuickEntryApp />
</I18nProvider>
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
@@ -228,7 +228,7 @@ function MarketplaceThemeResults({
const header = (
<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>
)
@@ -519,7 +519,7 @@ export function AppearanceSettings() {
<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)"
onChange={event => setQuery(event.target.value)}
placeholder="Search your themes or the VS Code Marketplace…"
placeholder={t.settings.runtime.themeSearch}
spellCheck={false}
value={query}
/>
@@ -531,7 +531,7 @@ export function AppearanceSettings() {
{filteredThemes.length === 0 ? (
needle ? (
<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>
) : null
) : (
@@ -11,6 +11,7 @@ import { RowValue } from './account-row-value'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
import { useBillingCopy } from './copy'
import { BillingRefusalInline } from './inline-feedback'
import type { BillingAutoReload, BillingStateResponse } from './types'
import type { BillingAccountRowView } from './use-billing-state'
@@ -24,6 +25,8 @@ export function AutoReloadRow({
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const copy = useBillingCopy()
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
@@ -45,7 +48,7 @@ export function AutoReloadRow({
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds, copy)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
@@ -95,7 +98,7 @@ export function AutoReloadRow({
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setMessage({ kind: 'success', text: copy.refillUpdated })
setEditing(false)
}
@@ -125,7 +128,7 @@ export function AutoReloadRow({
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setMessage({ kind: 'success', text: copy.refillOff })
setEditing(false)
}
@@ -178,9 +181,9 @@ export function AutoReloadRow({
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
<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)">
Threshold
{copy.threshold}
<Input
aria-label="Auto-refill threshold"
aria-label={copy.refillThreshold}
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
@@ -195,9 +198,9 @@ export function AutoReloadRow({
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
{copy.reloadTo}
<Input
aria-label="Auto-refill reload-to amount"
aria-label={copy.refillAmount}
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
@@ -218,9 +221,9 @@ export function AutoReloadRow({
</div>
{confirmDisable ? (
<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">
Turn off
{copy.turnOff}
</Button>
<Button
disabled={busy}
@@ -229,7 +232,7 @@ export function AutoReloadRow({
type="button"
variant="ghost"
>
Cancel
{copy.cancel}
</Button>
</div>
) : (
@@ -241,7 +244,7 @@ export function AutoReloadRow({
type="button"
variant="outline"
>
Disable
{copy.disable}
</Button>
)}
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
@@ -261,15 +264,15 @@ export function AutoReloadRow({
{editing ? (
<>
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
{busy ? copy.saving : copy.save}
</Button>
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
Cancel
{copy.cancel}
</Button>
</>
) : (
<Button onClick={openEdit} size="sm" type="button" variant="outline">
Manage
{copy.manage}
</Button>
)}
</div>
@@ -1,3 +1,4 @@
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
import type { BillingStateResponse } from './types'
import { EMPTY_BILLING_VALUE } from './use-billing-state'
@@ -49,22 +50,23 @@ export function initialAutoReloadAmount(...candidates: Array<null | string | und
export function validateAutoReloadInputs(
thresholdRaw: 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 } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
const threshold = validateBillingAmount(copy.threshold, thresholdRaw, bounds, copy)
if (threshold.error || threshold.amount == null) {
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) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
return { error: copy.reloadGreater }
}
return {
@@ -78,30 +80,31 @@ export function validateAutoReloadInputs(
export function validateBillingAmount(
label: 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 } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
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)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
return { error: copy.positiveAmount(label) }
}
const min = parseAmount(bounds.min_usd)
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)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
return { error: copy.maxAmount(label, formatMoney(max)) }
}
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 { ExternalLink } from '@/lib/icons'
import { useBillingCopy } from './copy'
import { BillingRefusalInline } from './inline-feedback'
import { openExternal } from './open-external'
import { TierArt } from './tier-art'
@@ -8,6 +9,8 @@ import type { BillingPlanCardView } from './use-billing-state'
import { useResumeFlow } from './use-subscription-change'
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
const copy = useBillingCopy()
const resumeFlow = useResumeFlow()
return (
@@ -22,7 +25,8 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
</span>
{plan.price && (
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.price}/mo
{plan.price}
{copy.monthSuffix}
</span>
)}
</div>
@@ -40,7 +44,7 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
{plan.pending && (
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
{resumeFlow.busy ? 'Undoing…' : 'Undo'}
{resumeFlow.busy ? copy.undoing : copy.undo}
</Button>
)}
{plan.link && (
+46 -53
View File
@@ -1,4 +1,5 @@
import type { BillingRefusal } from './api'
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
export interface BillingRefusalPresentation {
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 retryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
const retryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
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 mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
const stripeRetryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
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) {
case 'consent_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Confirm this card for terminal charges in the portal',
title: 'Card confirmation needed'
message: copy.confirmCardMessage,
title: copy.confirmCardTitle
}
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
title: 'Remote Spending needs approval'
message: copy.spendingApprovalMessage,
title: copy.spendingApprovalTitle
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
const who = refusal.actor === 'admin' ? copy.adminRevoked : copy.userRevoked
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Remote spending was stopped'
message: copy.reauthorize(who),
title: copy.spendingRevoked
}
}
case 'session_revoked':
return {
action: portalAction(refusal.portalUrl),
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
title: 'Session logged out'
message: copy.sessionRevokedMessage,
title: copy.sessionRevokedTitle
}
case 'cli_billing_disabled':
@@ -60,50 +61,44 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message:
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
title: 'Remote spending is off'
message: copy.spendingDisabledMessage,
title: copy.spendingDisabledTitle
}
case 'role_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
title: 'Admin role required'
message: copy.adminRequiredMessage,
title: copy.adminRequiredTitle
}
case 'idempotency_conflict':
return {
action: { type: 'none' },
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
title: 'Start a fresh top-up'
message: copy.conflictingChargeMessage,
title: copy.conflictingChargeTitle
}
case 'no_payment_method':
return {
action: portalAction(refusal.portalUrl),
message:
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card).",
title: 'No saved card'
message: copy.noSavedCardMessage,
title: copy.noSavedCard
}
case 'org_access_denied':
return {
action: { type: 'none' },
message: "This token isn't bound to an org you can manage",
title: 'Org access denied'
message: copy.orgDeniedMessage,
title: copy.orgDeniedTitle
}
case 'monthly_cap_exceeded': {
const remaining = refusal.payload?.remainingUsd
return {
action: portalAction(refusal.portalUrl),
message:
remaining != null
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
: '🔴 Monthly spend cap reached.',
title: 'Monthly spend cap reached'
message: remaining != null ? copy.monthlyRemaining(String(remaining)) : copy.monthlyReached,
title: copy.monthlyReachedTitle
}
}
@@ -112,52 +107,50 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'temporarily_unavailable':
return {
action: { type: 'retry' },
message: retryMessage(refusal),
title: 'Too many charges right now'
message: retryMessage(refusal, copy),
title: copy.rateLimitedTitle
}
case 'stripe_unavailable':
return {
action: { type: 'retry' },
message: stripeRetryMessage(refusal),
title: 'Stripe is having trouble'
message: stripeRetryMessage(refusal, copy),
title: copy.stripeTitle
}
case 'upgrade_cap_exceeded':
return {
action: { type: 'none' },
message: 'Daily plan-change limit reached — try again tomorrow',
title: 'Daily plan-change limit reached'
message: copy.dailyLimitMessage,
title: copy.dailyLimitTitle
}
case 'endpoint_unavailable':
return {
action: { type: 'retry' },
message:
refusal.message ||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
title: 'Billing endpoint unavailable'
message: refusal.message || copy.endpointMessage,
title: copy.endpointTitle
}
case 'timeout':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request timed out.',
title: 'Billing request timed out'
message: refusal.message || copy.requestTimeoutMessage,
title: copy.requestTimeoutTitle
}
case 'transport':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request failed before reaching the gateway.',
title: 'Billing connection failed'
message: refusal.message || copy.connectionFailedMessage,
title: copy.connectionFailedTitle
}
default:
return {
action: { type: 'none' },
message: refusal.message || 'Billing request failed.',
title: 'Billing request failed'
message: refusal.message || copy.requestFailedMessage,
title: copy.requestFailedTitle
}
}
}
@@ -1,9 +1,13 @@
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 { MemoryRouter } from 'react-router'
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 {
billingDevFixtures,
@@ -692,3 +696,38 @@ describe('BillingSettings', () => {
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()
})
+25 -14
View File
@@ -24,6 +24,7 @@ import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
import { AutoReloadRow } from './auto-reload-row'
import { clampAmount, formatMoney } from './billing-amounts'
import { useBillingCopy } from './copy'
import { CurrentPlanCard } from './current-plan-card'
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
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 }) {
const copy = useBillingCopy()
const presets = useMemo(
() =>
billing.charge_presets.map((amount, index) => ({
@@ -192,7 +195,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
value={amount}
/>
<Input
aria-label="Custom credit amount"
aria-label={copy.customCredit}
containerClassName="w-16"
disabled={controlsDisabled}
inputMode="decimal"
@@ -211,7 +214,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
Buy
{copy.buy}
</Button>
</div>
}
@@ -250,12 +253,14 @@ function BuyCreditsOutcome({
onRetry: () => void
outcome: ReturnType<typeof useChargeFlow>['outcome']
}) {
const copy = useBillingCopy()
const stepUp = useStepUpFlow()
if (busy) {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Processing checking settlement
{copy.processing}
</div>
)
}
@@ -267,7 +272,7 @@ function BuyCreditsOutcome({
if (outcome.kind === 'success') {
return (
<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>
)
}
@@ -280,7 +285,7 @@ function BuyCreditsOutcome({
</span>
{outcome.portalUrl && (
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
Open portal
{copy.portal}
<ExternalLink className="size-3.5" />
</Button>
)}
@@ -297,13 +302,13 @@ function BuyCreditsOutcome({
</span>
{outcome.action?.type === 'retry' && (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
{copy.retry}
</Button>
)}
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
{copy.portal}
<ExternalLink className="size-3.5" />
</Button>
)}
@@ -312,8 +317,10 @@ function BuyCreditsOutcome({
}
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
const copy = useBillingCopy()
const resolvedBar = bar ?? {
label: `${fallbackLabel} usage`,
label: copy.usageLabel(fallbackLabel),
state: 'neutral',
tone: 'topup',
value: 0
@@ -407,11 +414,13 @@ function BillingHeader({
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const copy = useBillingCopy()
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="flex min-w-0 items-center gap-2">
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
<span>Billing</span>
<span>{copy.billing}</span>
</div>
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
@@ -455,6 +464,8 @@ function BillingSettingsContent({
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const copy = useBillingCopy()
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
// Fixture mode flows through the SAME query path — the simulated api (supplied by
@@ -476,7 +487,7 @@ function BillingSettingsContent({
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const view = deriveBillingView(billingResult, subscriptionResult, copy)
const billing = billingResult?.ok ? billingResult.data : undefined
const { paymentRow, refillRow, topupRow } = view
@@ -515,7 +526,7 @@ function BillingSettingsContent({
</div>
{view.plan && (
<SettingsSection icon={Package} title="Plan">
<SettingsSection icon={Package} title={copy.plan}>
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</SettingsSection>
)}
@@ -524,7 +535,7 @@ function BillingSettingsContent({
<SettingsSection
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
icon={CreditCard}
title="Payment & credits"
title={copy.paymentCredits}
>
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
@@ -533,7 +544,7 @@ function BillingSettingsContent({
)}
{view.usageRows.length > 0 && (
<SettingsSection icon={BarChart3} title="Usage">
<SettingsSection icon={BarChart3} title={copy.usage}>
<div className="@container">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
@@ -544,7 +555,7 @@ function BillingSettingsContent({
{
// 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>
)
@@ -3,16 +3,19 @@ import { openExternalLink } from '@/lib/external-link'
import { ExternalLink } from '@/lib/icons'
import type { BillingRefusal } from './api'
import { useBillingCopy } from './copy'
import { resolveRefusal } from './errors'
import { useStepUpFlow } from './use-step-up'
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
const copy = useBillingCopy()
if (flow.verification) {
return (
<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>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
{copy.verificationPage}
<ExternalLink className="size-3.5" />
</Button>
</span>
@@ -26,31 +29,33 @@ export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUp
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
{copy.dismiss}
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
return <span>{copy.waitingVerification}</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
{copy.verifyContinue}
</Button>
)
}
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const copy = useBillingCopy()
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const resolved = resolveRefusal(refusal, copy)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
@@ -61,7 +66,7 @@ export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | nu
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
Open portal
{copy.portal}
<ExternalLink className="size-3.5" />
</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 { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
import { BillingRefusalInline } from './inline-feedback'
import { TierArt } from './tier-art'
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
// 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') {
return 'Checking this change…'
return copy.checkingChange
}
if (phase.kind === 'previewFailed') {
@@ -27,28 +32,27 @@ function previewMessage(phase: DowngradePhase, fallbackTierName: string): null |
const { preview } = phase
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) {
case 'blocked':
return preview.reason ?? 'That change cannot be made here.'
return preview.reason ?? copy.cannotChange
case 'no_op':
return `You are already on ${targetName} — nothing to change.`
return copy.alreadyPlan(targetName)
case 'scheduled':
return (
`Change to ${targetName} — takes effect ${formatBillingDate(preview.effective_at)}. No charge now; ` +
`you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
)
return copy.scheduledChange(targetName, formatBillingDate(preview.effective_at, copy), creditsDelta)
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).
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const copy = useBillingCopy()
const active = flow.active
const panelRef = useRef<HTMLDivElement>(null)
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 refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
const message = previewMessage(phase, tier.name)
const message = previewMessage(phase, tier.name, copy)
const canConfirm =
(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">
{phase.kind === 'previewFailed' ? (
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
Try again
{copy.tryAgain}
</Button>
) : canConfirm ? (
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
{phase.kind === 'scheduling'
? 'Scheduling…'
? copy.scheduling
: phase.kind === 'scheduleFailed'
? 'Try again'
: 'Confirm downgrade'}
? copy.tryAgain
: copy.confirmDowngrade}
</Button>
) : null}
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
Cancel
{copy.cancel}
</Button>
</div>
</div>
@@ -111,6 +115,8 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
}
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const copy = useBillingCopy()
const isCurrent = tier.state === 'current'
const confirming = flow.active?.target.tierId === tier.tierId
const cardRef = useRef<HTMLDivElement>(null)
@@ -143,7 +149,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
{tier.name}
</div>
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.priceDisplay}/mo
{tier.priceDisplay}
{copy.monthSuffix}
</div>
</div>
</div>
@@ -155,9 +162,9 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
)}
<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' && (
<Button
@@ -183,7 +190,7 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
type="button"
variant="outline"
>
Downgrade
{copy.downgrade}
</Button>
))}
</div>
@@ -192,6 +199,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
}
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
// now shows the pending state with its undo.
const flow = useDowngradeFlow({ onScheduled: onBack })
@@ -200,7 +209,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
<div className="@container">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Button
aria-label="Back to billing"
aria-label={copy.backBilling}
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={flow.mutating}
onClick={onBack}
@@ -210,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
>
<ChevronLeft className="size-4" />
</Button>
<span>Plans</span>
<span>{copy.plans}</span>
</div>
{tiers.length > 0 ? (
@@ -221,7 +230,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
</div>
) : (
<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>
@@ -1,9 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { fmtDate } from '@/lib/time'
import type { BillingRefusal, BillingResult } from './api'
import { useBillingApi } from './api'
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
import { resolveRefusal } from './errors'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
@@ -30,7 +29,7 @@ const BILLING_QUERY_OPTIONS = {
} as const
export interface BillingSummaryItemView {
label: 'Auto-refill' | 'Balance' | 'Plan'
label: string
tone?: 'muted' | 'primary'
value: string
}
@@ -172,12 +171,13 @@ export function useSubscriptionState(enabled = true) {
export function deriveBillingView(
stateResult?: BillingResult<BillingStateResponse>,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
subscriptionResult?: BillingResult<SubscriptionStateResponse>,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingView {
if (!stateResult) {
return {
status: 'loading',
summary: emptySummary(),
summary: emptySummary(copy),
tiers: [],
usageRows: []
}
@@ -185,9 +185,9 @@ export function deriveBillingView(
if (!stateResult.ok) {
return {
notice: refusalNotice(stateResult.refusal),
notice: refusalNotice(stateResult.refusal, copy),
status: 'refusal',
summary: emptySummary(),
summary: emptySummary(copy),
tiers: [],
usageRows: []
}
@@ -199,12 +199,12 @@ export function deriveBillingView(
if (!billing.logged_in || subscription?.logged_in === false) {
return {
notice: {
action: { label: 'Open portal ↗', 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.',
title: 'Connect your Nous account'
action: { label: copy.portalArrow, url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
message: copy.connectMessage,
title: copy.connectTitle
},
status: 'logged_out',
summary: emptySummary(),
summary: emptySummary(copy),
tiers: [],
usageRows: []
}
@@ -216,27 +216,27 @@ export function deriveBillingView(
const capable = plansCapable(subscription, subscriptionResult)
// Computed once and threaded to both the card (caption + undo) and the grid
// (Scheduled marker), so the two never disagree about what's pending.
const pending = pendingTransition(subscription?.current)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
const pending = pendingTransition(subscription?.current, copy)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending, copy)
return {
notice: noCardNotice(billing),
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
notice: noCardNotice(billing, copy),
paymentRow: paymentMethodRow(billing, copy),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending, copy),
refillRow: autoReloadRow(billing, copy),
status: 'normal',
summary: [
{ label: 'Balance', value: displayBalance(billing) },
{ label: 'Plan', value: displayPlan(subscription, billing.usage) },
{ label: copy.balance, value: displayBalance(billing) },
{ 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,
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,
topupRow: buyCreditsRow(billing),
usageRows: deriveUsageRows(billing, subscription)
topupRow: buyCreditsRow(billing, copy),
usageRows: deriveUsageRows(billing, subscription, copy)
}
}
@@ -275,7 +275,7 @@ export function buildManageSubscriptionUrl(
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) {
return EMPTY_BILLING_VALUE
}
@@ -286,23 +286,23 @@ export function formatBillingDate(value?: null | string): string {
return EMPTY_BILLING_VALUE
}
return fmtDate.format(date)
return copy.formatDate(date)
}
function emptySummary(): BillingSummaryItemView[] {
function emptySummary(copy: BillingCopy = DEFAULT_BILLING_COPY): BillingSummaryItemView[] {
return [
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
{ label: 'Plan', value: EMPTY_BILLING_VALUE },
{ label: 'Auto-refill', value: EMPTY_BILLING_VALUE }
{ label: copy.balance, value: EMPTY_BILLING_VALUE },
{ label: copy.plan, value: EMPTY_BILLING_VALUE },
{ label: copy.autoRefill, value: EMPTY_BILLING_VALUE }
]
}
function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
const resolved = resolveRefusal(refusal)
function refusalNotice(refusal: BillingRefusal, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingNoticeView {
const resolved = resolveRefusal(refusal, copy)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return {
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
action: portalUrl ? { label: copy.portalArrow, url: portalUrl } : undefined,
message: resolved.message,
title: resolved.title,
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
// 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.
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
function noCardNotice(
billing: BillingStateResponse,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingNoticeView | undefined {
if (billing.card) {
return undefined
}
return {
action: { label: 'Add card ↗', 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.',
title: 'No payment method on file',
action: { label: copy.addCard, url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
message: copy.noCardMessage,
title: copy.noCardTitle,
tone: 'warn'
}
}
@@ -348,10 +351,13 @@ function plansCapable(
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
// 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, ''))
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
* 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)
if (amount == null || amount === 0) {
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,
tiers: BillingPlanTierView[],
capable: boolean,
pending: PendingPlanTransition | undefined
pending: PendingPlanTransition | undefined,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingPlanCardView {
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
// still shows its price.
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 caption = unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
? copy.subscriptionUnavailable
: pending
? pending.kind === 'downgrade'
? `Changes to ${pending.tierName} on ${pending.when}.`
: `Cancels on ${pending.when}.`
? copy.changesPlan(pending.tierName, pending.when)
: copy.cancelsOn(pending.when)
: current
? `Renews ${renewal}`
: 'No active subscription — paid models draw down top-up credits.'
? copy.renewsOn(renewal)
: copy.noSubscription
// 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
@@ -410,14 +420,14 @@ function derivePlanCard(
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
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 {
caption,
// No in-app action → always hand off to the portal so the user isn't stranded.
link: {
label: 'Adjust plan ↗',
label: copy.adjustPlan,
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
},
pending,
@@ -432,20 +442,21 @@ function derivePlanCard(
// 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.
function pendingTransition(
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>,
copy: BillingCopy = DEFAULT_BILLING_COPY
): PendingPlanTransition | undefined {
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
return {
kind: 'downgrade',
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) {
return {
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,
fallbackPortalUrl: null | string,
capable: boolean,
pending: PendingPlanTransition | undefined
pending: PendingPlanTransition | undefined,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingPlanTierView[] {
if (!capable || !subscription) {
return []
@@ -503,7 +515,7 @@ function derivePlanTiers(
return gridTiers.map((tier): BillingPlanTierView => {
const base: BillingPlanTierBase = {
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits, copy),
name: tier.name,
priceDisplay: tier.dollars_per_month_display,
tierId: tier.tier_id
@@ -529,13 +541,16 @@ function derivePlanTiers(
return {
...base,
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
action: { label: copy.choose, url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
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 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
// notice, so the row stays a bare call-to-action with no redundant status text.
return {
action: { label: 'Add payment method', url: portalUrl },
action: { label: copy.addPayment, url: portalUrl },
description: '',
id: 'payment_method',
title: 'Payment method'
title: copy.paymentMethod
}
}
return {
action: { label: 'Update', url: portalUrl },
description: 'Manage the card used for top-ups and subscription renewals.',
action: { label: copy.update, url: portalUrl },
description: copy.manageCard,
id: 'payment_method',
title: 'Payment method',
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via)}`
title: copy.paymentMethod,
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) {
// 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,
// so keep the plain "what buying does" line and let the controls sit disabled.
return {
action: { disabled: true, label: 'Buy' },
action: { disabled: true, label: copy.buy },
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',
title: 'Buy credits now'
title: copy.buyCredits
}
}
const disabledReason = buyCreditsDisabledReason(billing)
const disabledReason = buyCreditsDisabledReason(billing, copy)
if (disabledReason) {
return {
description: disabledReason,
id: 'buy_credits',
title: 'Buy credits now'
title: copy.buyCredits
}
}
return {
action: { disabled: true, label: 'Buy' },
action: { disabled: true, label: copy.buy },
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',
title: 'Buy credits now'
title: copy.buyCredits
}
}
// The generic first sentence shared by the off / absent / divergent states,
// where the concrete amounts aren't the headline. The configured state overrides
// 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
if (!autoReload) {
return {
action: { disabled: true, label: 'Manage' },
caption: 'Manage auto-refill from the portal.',
description: AUTO_REFILL_GENERIC,
action: { disabled: true, label: copy.manage },
caption: copy.manageRefill,
description: copy.refillDescription,
id: 'auto_reload',
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
title: 'Refill when low'
title: copy.refillWhenLow
}
}
if (!autoReload.enabled) {
return {
caption: 'Turn on auto-refill from the portal',
description: AUTO_REFILL_GENERIC,
caption: copy.turnOnRefill,
description: copy.refillDescription,
id: 'auto_reload',
pill: { label: 'Off', tone: 'muted' },
title: 'Refill when low'
pill: { label: copy.off, tone: 'muted' },
title: copy.refillWhenLow
}
}
@@ -626,16 +640,16 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
// the default enabled path below — the same treatment as a canonical card.
if (autoReload.card?.kind === 'distinct') {
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
return {
action: { label: 'Reconcile ↗', url: portalUrl },
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
description: AUTO_REFILL_GENERIC,
action: { label: copy.reconcile, url: portalUrl },
caption: copy.reconcileCard(cardLabel),
description: copy.refillDescription,
id: 'auto_reload',
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
pill: { label: copy.enabled, tone: 'primary' },
title: copy.refillWhenLow
}
}
@@ -643,22 +657,23 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
return {
action: { label: 'Manage' },
action: { label: copy.manage },
// Numbers live in the first sentence (spec §8); the swap region below carries
// 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',
// The only row that edits in place — AutoReloadRow keys its swap layout off this
// flag rather than sniffing the action label.
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
pill: { label: copy.enabled, tone: 'primary' },
title: copy.refillWhenLow
}
}
function deriveUsageRows(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse
subscription: null | SubscriptionStateResponse,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingUsageRowView[] {
const rows: BillingUsageRowView[] = []
const current = subscription?.current
@@ -671,8 +686,8 @@ function deriveUsageRows(
const subscriptionValue =
remaining != null && monthly != null
? remaining < 0
? `${formatMoney(0)} of ${formatMoney(monthly)} left · ${formatMoney(Math.abs(remaining))} over`
: `${formatMoney(remaining)} of ${formatMoney(monthly)} left`
? copy.remaining(formatMoney(0), formatMoney(monthly), formatMoney(Math.abs(remaining)))
: copy.remaining(formatMoney(remaining), formatMoney(monthly))
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
@@ -681,16 +696,16 @@ function deriveUsageRows(
bar:
remainingFraction != null
? {
label: 'Subscription credits remaining',
label: copy.subscriptionRemaining,
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
tone: 'subscription',
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
value: clamp01(remainingFraction)
}
: 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',
title: 'Subscription credits',
title: copy.subscriptionCredits,
value: subscriptionValue
})
@@ -699,9 +714,9 @@ function deriveUsageRows(
// 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.
rows.push({
caption: 'Does not expire',
caption: copy.noExpiry,
id: 'topup_credits',
title: 'Top-up credits',
title: copy.topupCredits,
value: topupValue
})
@@ -711,22 +726,22 @@ function deriveUsageRows(
const limit = parseAmount(cap.limit_usd)
const spent = parseAmount(cap.spent_this_month_usd) ?? 0
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({
bar:
usedFraction != null
? {
label: 'Monthly spend cap used',
label: copy.capUsed,
state: usedFraction >= 0.9 ? 'danger' : 'ok',
tone: 'cap',
track: usedFraction >= 1 ? 'danger' : undefined,
value: clamp01(usedFraction)
}
: undefined,
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
caption: cap.is_default_ceiling ? copy.defaultCeiling : copy.monthlyRemoteSpending,
id: 'monthly_cap',
title: 'Monthly spend cap',
title: copy.monthlyCap,
value
})
}
@@ -738,7 +753,11 @@ function displayBalance(billing: BillingStateResponse): string {
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 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
return price ? `${tier} · ${price}/mo` : tier
return price ? `${tier} · ${price}${copy.monthSuffix}` : tier
}
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) {
return resolveRefusal({ kind: 'role_required', message: '' }).message
return resolveRefusal({ kind: 'role_required', message: '' }, copy).message
}
if (!billing.cli_billing_enabled) {
return resolveRefusal({ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
return resolveRefusal(
{ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
copy
).message
}
if (!billing.can_charge) {
return resolveRefusal({ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
return resolveRefusal(
{ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
copy
).message
}
return null
}
function provenanceSuffix(resolvedVia?: null | string): string {
function provenanceSuffix(resolvedVia?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
if (!resolvedVia) {
return ''
}
const labels: Record<string, string> = {
autoRefill: 'auto-refill card',
customerDefault: 'customer default',
subPin: 'subscription card'
autoRefill: copy.refillCard,
customerDefault: copy.customerDefault,
subPin: copy.subscriptionCard
}
return ` - ${labels[resolvedVia] ?? resolvedVia}`
@@ -3,6 +3,8 @@ import { act, renderHook } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import type { BillingResult } from './api'
import type { BillingChargeStatusResponse } from './types'
@@ -58,7 +60,11 @@ function controlledClock() {
function wrapper({ children }: PropsWithChildren) {
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(() => {
@@ -9,6 +9,7 @@ import { useCallback, useRef, useState } from 'react'
import type { BillingApi, BillingRefusal } from './api'
import { useBillingApi } from './api'
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
import { resolveRefusal } from './errors'
import type { BillingChargeStatusResponse } from './types'
@@ -43,6 +44,7 @@ export interface ChargePollClock {
}
export interface ChargePollOptions extends ChargePollClock {
copy?: BillingCopy
portalUrl?: null | string
}
@@ -66,6 +68,7 @@ export async function pollChargeSettlement(
chargeId: string,
opts: ChargePollOptions = {}
): Promise<ChargeFlowOutcome> {
const copy = opts.copy ?? DEFAULT_BILLING_COPY
const sleep = opts.sleep ?? defaultSleep
const now = opts.now ?? Date.now
const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {}
@@ -96,51 +99,51 @@ export async function pollChargeSettlement(
return {
amountUsd: settlement.status.amount_usd,
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':
return {
action: { type: 'retry' },
kind: 'failure',
message: renderChargeFailed(settlement.status.reason),
message: renderChargeFailed(settlement.status.reason, copy),
retryFreshKey: true,
title: 'Charge failed'
title: copy.chargeFailedTitle
}
case 'ambiguous': {
if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) {
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
return {
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,
title: 'Charge outcome unconfirmed'
title: copy.unconfirmedTitle
}
}
return {
kind: 'failure',
message: observed.refusal?.message || 'Could not check the charge.',
message: observed.refusal?.message || copy.checkFailedMessage,
retryFreshKey: true,
title: 'Could not check charge'
title: copy.checkFailedTitle
}
}
case 'refused':
return {
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,
title: 'Could not check charge'
title: copy.checkFailedTitle
}
case 'cancelled':
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() {
const copy = useBillingCopy()
const api = useBillingApi()
const queryClient = useQueryClient()
const [phase, setPhase] = useState<ChargeFlowPhase>('idle')
@@ -206,7 +210,7 @@ export function useChargeFlow() {
const chargeResult = await api.charge(amountUsd, idempotencyKey)
if (!chargeResult.ok) {
const resolved = resolveRefusal(chargeResult.refusal)
const resolved = resolveRefusal(chargeResult.refusal, copy)
const action =
resolved.action.type === 'portal'
@@ -239,9 +243,9 @@ export function useChargeFlow() {
if (!chargeId) {
setOutcome({
kind: 'failure',
message: 'The billing service accepted the request but did not return a charge id.',
message: copy.missingChargeId,
retryFreshKey: true,
title: 'Charge could not be tracked'
title: copy.untrackedTitle
})
setPhaseState('done')
@@ -251,6 +255,7 @@ export function useChargeFlow() {
setPhaseState('polling')
const pollOutcome = await pollChargeSettlement(api, chargeId, {
copy,
portalUrl: chargeResult.data.portal_url
})
@@ -261,7 +266,7 @@ export function useChargeFlow() {
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
}
},
[api, queryClient, setPhaseState]
[api, queryClient, setPhaseState, copy]
)
return { outcome, phase, reset, start }
@@ -271,27 +276,27 @@ function shouldReuseIdempotencyKey(refusal: BillingRefusal): boolean {
return retryableSendKinds.has(refusal.kind)
}
function timeoutOutcome(portalUrl?: null | string): ChargeFlowOutcome {
function timeoutOutcome(portalUrl?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): ChargeFlowOutcome {
return {
kind: 'ambiguous',
message: 'Charge may still settle. Check the portal before retrying.',
message: copy.stillProcessingMessage,
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()) {
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':
return 'Your card has expired. Update it on the portal.'
return copy.cardExpired
case 'card_declined':
return 'Your card was declined. Try another card on the portal.'
return copy.cardDeclined
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
const apiMocks = vi.hoisted(() => ({
stepUp: vi.fn()
}))
@@ -56,7 +58,11 @@ import { useStepUpFlow } from './use-step-up'
function createWrapper(client: QueryClient) {
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 { useBillingApi } from './api'
import { useBillingCopy } from './copy'
import { resolveRefusal } from './errors'
export type StepUpPhase = 'idle' | 'verifying' | 'waiting'
@@ -26,6 +27,7 @@ interface StepUpVerificationPayload {
}
export function useStepUpFlow() {
const copy = useBillingCopy()
const api = useBillingApi()
const gateway = useStore($gateway)
const queryClient = useQueryClient()
@@ -107,7 +109,7 @@ export function useStepUpFlow() {
unsubscribe()
if (!result.ok) {
const resolved = resolveRefusal(result.refusal)
const resolved = resolveRefusal(result.refusal, copy)
setMessage({
kind: 'error',
@@ -121,8 +123,8 @@ export function useStepUpFlow() {
if (!result.data.granted) {
setMessage({
kind: 'error',
text: 'Verification finished without allowing Remote Spending for this terminal.',
title: 'Verification was not approved'
text: copy.verificationDeniedMessage,
title: copy.verificationDeniedTitle
})
return
@@ -134,10 +136,10 @@ export function useStepUpFlow() {
])
setMessage({
kind: 'success',
text: 'Remote Spending is allowed for this terminal.',
title: 'Verification complete'
text: copy.verificationDoneMessage,
title: copy.verificationDoneTitle
})
}, [api, gateway, queryClient, unsubscribe])
}, [api, gateway, queryClient, unsubscribe, copy])
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 { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/**
@@ -34,6 +35,7 @@ export function ComboboxInput({
placeholder?: string
className?: string
}) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -66,7 +68,7 @@ export function ComboboxInput({
value={value}
/>
<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"
onClick={() => {
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 { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
import { upsertDesktopActionTask } from '@/store/activity'
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
// 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) {
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 }) {
const { t } = useI18n()
const copy = t.settings.runtime.computerUse
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<div className="min-w-0">
@@ -41,7 +41,7 @@ function PermissionRow({ granted, label, hint }: { granted: boolean | null; labe
</div>
<Pill tone={tone(granted)}>
<GrantIcon granted={granted} />
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
{granted === true ? copy.granted : granted === false ? copy.notGranted : copy.unknown}
</Pill>
</div>
)
@@ -61,6 +61,8 @@ function PermissionRow({ granted, label, hint }: { granted: boolean | null; labe
* below this card (the generic ToolsetConfigPanel).
*/
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
const { t } = useI18n()
const copy = t.settings.runtime.computerUse
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
const [loading, setLoading] = useState(true)
const [granting, setGranting] = useState(false)
@@ -70,11 +72,11 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
try {
setStatus(await getComputerUseStatus())
} catch (err) {
notifyError(err, 'Could not read Computer Use status')
notifyError(err, copy.statusFailed)
} finally {
setLoading(false)
}
}, [])
}, [copy.statusFailed])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
@@ -91,15 +93,15 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
const started = await grantComputerUsePermissions()
if (!started.ok) {
notifyError(new Error('spawn failed'), 'Could not request permissions')
notifyError(new Error('spawn failed'), copy.permissionsFailed)
return
}
notify({
kind: 'info',
title: 'Approve in System Settings',
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
title: copy.approveTitle,
message: copy.approveBody
})
// 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) {
if (activeRef.current) {
notifyError(err, 'Could not request permissions')
notifyError(err, copy.permissionsFailed)
}
} finally {
if (activeRef.current) {
setGranting(false)
}
}
}, [onConfiguredChange, refresh])
}, [onConfiguredChange, refresh, copy])
if (loading) {
return (
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking Computer Use status
{copy.checking}
</div>
)
}
@@ -147,18 +149,14 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
}
if (!status.platform_supported) {
return (
<p className="px-1 text-xs text-muted-foreground">
Computer Use isn&apos;t supported on this platform ({status.platform}).
</p>
)
return <p className="px-1 text-xs text-muted-foreground">{copy.unsupported(status.platform)}</p>
}
if (!status.installed) {
return (
<p className="px-1 text-xs text-muted-foreground">
Install the cua-driver backend below to drive this machine.
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
{copy.install}
{status.can_grant && copy.installGrant}
</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="min-w-0">
{status.can_grant ? (
<p className="text-[0.72rem] text-muted-foreground">
Grants attach to CuaDriver&apos;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">{copy.identity}</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>}
</div>
<Button onClick={() => void refresh()} size="sm" variant="text">
<RefreshCw className="size-3.5" />
Recheck
{copy.recheck}
</Button>
</div>
{status.can_grant ? (
<>
<PermissionRow
granted={status.accessibility}
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
label="Accessibility"
/>
<PermissionRow granted={status.accessibility} hint={copy.accessibilityHint} label={copy.accessibility} />
<PermissionRow
granted={status.screen_recording}
hint="Lets cua-driver capture screenshots of app windows."
label="Screen Recording"
hint={copy.screenRecordingHint}
label={copy.screenRecording}
/>
</>
) : (
<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)}>
<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>
</div>
)}
@@ -225,13 +218,13 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
{status.ready ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Check className="size-3.5" />
Computer Use is ready. Ask the agent to capture an app and click around.
{copy.readyHint}
</div>
) : (
status.can_grant && (
<Button disabled={granting} onClick={() => void grant()} size="sm">
{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>
)
)}
@@ -146,7 +146,7 @@ export function ConfigField({
{selectOptions.map(option => (
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
{option
? (optionLabels?.[option] ?? prettyName(option))
? (optionLabels?.[option] ?? t.settings.fieldOptions?.[schemaKey]?.[option] ?? prettyName(option))
: schemaKey === 'display.personality'
? c.none
: 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 type { DesktopConnectionsRegistry } from '@/global'
@@ -11,6 +11,7 @@ import {
sameBackendPeerLabel,
sshCompositeKey
} from './connections-registry'
import { renderWithEnglish as render } from './test-locale'
const list = vi.fn()
const save = vi.fn()
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { prettyName, withoutKey } from './helpers'
import { prettyName, providerDescription, withoutKey } from './helpers'
import { ListRow } from './primitives'
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. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
const { t } = useI18n()
const { t, locale } = useI18n()
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)
return (
@@ -1,7 +1,9 @@
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 { renderWithEnglish as render } from './test-locale'
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
beforeAll(() => {
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 { renderWithEnglish as render } from './test-locale'
const getConnectionConfig = vi.fn()
const saveConnectionConfig = vi.fn()
+9
View File
@@ -1,3 +1,4 @@
import { TR_PROVIDER_DESCRIPTIONS } from '@/i18n/tr-provider-descriptions'
import { asText, normalize } from '@/lib/text'
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 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'])
function isSafePart(part: string): boolean {
@@ -104,7 +104,7 @@ const REFUSED_MODEL: LocalCatalogModel = {
function renderPane() {
return render(
<MemoryRouter>
<I18nProvider>
<I18nProvider configClient={null} initialLocale="en">
<LocalModelsSettings />
</I18nProvider>
</MemoryRouter>
@@ -429,7 +429,7 @@ describe('BrowseSection', () => {
render(
<MemoryRouter>
<I18nProvider>
<I18nProvider configClient={null} initialLocale="en">
<LocalModelsSettings />
</I18nProvider>
</MemoryRouter>
@@ -534,7 +534,7 @@ describe('quickstart completion navigation', () => {
render(
<MemoryRouter initialEntries={['/settings']}>
<I18nProvider>
<I18nProvider configClient={null} initialLocale="en">
<LocalModelsSettings />
</I18nProvider>
<Probe />
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
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
// module, so non-OAuth providers render nothing.
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 [connected, setConnected] = useState(false)
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
@@ -75,8 +78,8 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
await startMemoryProviderOAuth(provider, profile)
} catch (err) {
setPhase('error')
setDetail('Could not start the connection.')
notifyError(err, 'Failed to start connection')
setDetail(copy.startFailed)
notifyError(err, copy.startFailed)
return
}
@@ -92,7 +95,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
if (Date.now() > deadline.current) {
stop()
setPhase('error')
setDetail('Timed out — try again.')
setDetail(copy.timeout)
}
return
@@ -104,7 +107,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
if (next.state === 'error') {
setPhase('error')
setDetail(next.detail || 'Connection failed.')
setDetail(next.detail || copy.connectionFailed)
} else {
setPhase('idle')
}
@@ -113,7 +116,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
}
})()
}, POLL_MS)
}, [profile, provider, stop])
}, [profile, provider, stop, copy])
const cancel = useCallback(() => {
stop()
@@ -124,24 +127,24 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
return null
}
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
const connectLabel = connected ? (auth === 'apikey' ? copy.connectOauth : copy.reconnect) : copy.connect
return (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{phase === 'idle' && connected && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Check className="size-3" />
{auth === 'apikey' ? 'api key set' : 'oauth set'}
{auth === 'apikey' ? copy.apiKeySet : copy.oauthSet}
</span>
)}
{phase === 'pending' ? (
<>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for browser consent
{copy.waiting}
</span>
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
Cancel
{t.common.cancel}
</Button>
</>
) : (
@@ -3,6 +3,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Check, Info } from '@/lib/icons'
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.
export function FieldTitle({ field }: { field: MemoryProviderField }) {
const { t } = useI18n()
if (!field.info) {
return <>{field.label}</>
}
@@ -21,7 +24,7 @@ export function FieldTitle({ field }: { field: MemoryProviderField }) {
<span className="inline-flex items-center gap-1.5">
{field.label}
<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>
</span>
)
@@ -41,6 +44,8 @@ export function FieldControl({
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
onCommit?: (value: string) => void
}) {
const { t } = useI18n()
const set = (next: string) => {
onChange(next)
onCommit?.(next)
@@ -103,14 +108,14 @@ export function FieldControl({
className={`w-full ${FIELD_INPUT}`}
onBlur={commitDraft}
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"
value={value}
/>
{field.is_set && (
<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)" />
set
{t.settings.runtime.memory.set}
</span>
)}
</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 type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { renderWithEnglish as render } from '../test-locale'
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
@@ -12,6 +12,8 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { saveMemoryProviderConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
@@ -59,6 +61,8 @@ export function ProviderConfigModal({
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void> | void
}) {
const { t, locale } = useI18n()
const copy = t.settings.runtime.memory
const activeProfile = useStore($activeGatewayProfile)
const [values, setValues] = useState<Record<string, string>>({})
const [seeded, setSeeded] = useState<Record<string, string>>({})
@@ -81,11 +85,11 @@ export function ProviderConfigModal({
try {
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()
onOpenChange(false)
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
notifyError(err, copy.saveFailed(config.label))
} finally {
setSaving(false)
}
@@ -95,11 +99,8 @@ export function ProviderConfigModal({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
<DialogHeader>
<DialogTitle icon={SlidersHorizontal}>{config.label} full configuration</DialogTitle>
<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>
<DialogTitle icon={SlidersHorizontal}>{copy.fullTitle(config.label)}</DialogTitle>
<DialogDescription>{copy.fullDescription(config.label, profile ?? activeProfile)}</DialogDescription>
{config.docs_url && (
<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"
@@ -111,17 +112,18 @@ export function ProviderConfigModal({
rel="noreferrer"
target="_blank"
>
{config.label} configuration reference
{copy.reference(config.label)}
<ExternalLink className="size-3" />
</a>
)}
</DialogHeader>
<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}>
<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>
<div className="pl-1">
{fields.map(field => (
@@ -141,18 +143,19 @@ export function ProviderConfigModal({
))}
</div>
</section>
))}
)
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
</DialogClose>
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save changes
{copy.saveChanges}
</Button>
</DialogFooter>
</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 { I18nProvider } from '@/i18n'
import type { MemoryProviderConfig } from '@/types/hermes'
import { renderWithEnglish as render } from '../test-locale'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
@@ -198,6 +201,24 @@ describe('ProviderConfigPanel', () => {
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 () => {
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 { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
import { SlidersHorizontal } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
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 }) {
const { t, locale } = useI18n()
const copy = t.settings.runtime.memory
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [loadError, setLoadError] = useState<null | string>(null)
const [values, setValues] = useState<Record<string, string>>({})
@@ -38,9 +42,9 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
setLoadError(null)
} catch (err) {
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(() => {
setConfig(null)
@@ -71,10 +75,10 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
setSaved(current => ({ ...current, [field.key]: value }))
}
} 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.
@@ -87,20 +91,21 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
return (
<div className="flex items-center justify-between gap-3 py-2">
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Memory provider settings failed to load: {loadError}
{copy.loadFailed}: {loadError}
</span>
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
Retry
{t.common.retry}
</Button>
</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 secretFields = config.fields.filter(field => field.kind === 'secret')
const fields = config.fields.map(field => localizeMemoryField(config.name, field, locale))
const inlineFields = fields.filter(field => field.inline)
const secretFields = fields.filter(field => field.kind === 'secret')
const hasFullConfig = config.fields.some(field => !field.inline)
return (
@@ -114,16 +119,16 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
>
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
{copy.settings(config.label)}
</span>
{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>
{hasFullConfig && (
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
<SlidersHorizontal className="size-3.5" />
Full config
{copy.fullConfig}
</Button>
)}
</div>
@@ -1,8 +1,10 @@
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 { 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
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
// them to let the dropdown open in tests.
@@ -394,8 +396,8 @@ describe('ModelSettings', () => {
fireEvent.click(applyButton)
// The switch-time notice names the pinned provider and offers a reset.
expect(await screen.findByText(/still run on/)).toBeTruthy()
expect(screen.getByText('nous')).toBeTruthy()
expect(await screen.findByText(/still run on 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 () => {
@@ -157,6 +157,8 @@ interface StaleAuxWarningProps {
// $0-balance provider after switching main away from it) and offers the
// existing one-click reset rather than auto-clearing legitimate pins.
function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) {
const { t } = useI18n()
if (!slots.length) {
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">
<AlertTriangle className="size-3.5 shrink-0" />
<span className="grow">
{slots.length} auxiliary task{slots.length === 1 ? '' : 's'} ({names}) still run on{' '}
<span className="font-mono">{allSameProvider ? provider : 'other providers'}</span>, not your main model.
{t.settings.runtime.auxiliaryWarning(
slots.length,
names,
allSameProvider ? provider : t.settings.runtime.otherProviders
)}
</span>
<Button disabled={applying} onClick={onReset} size="sm" variant="textStrong">
Reset all to main
{t.settings.model.resetAllToMain}
</Button>
</div>
)
@@ -852,7 +857,9 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
void activateApiKeyProvider()
}
}}
placeholder={`Paste ${selectedProviderRow?.key_env ?? 'API key'}`}
placeholder={t.settings.runtime.pasteProviderKey(
selectedProviderRow?.key_env ?? t.settings.runtime.apiKey
)}
type="password"
value={apiKeyDraft}
/>
@@ -862,12 +869,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
>
{activating && <Loader2 className="size-3.5 animate-spin" />}
{activating ? 'Activating...' : 'Activate'}
{activating ? t.settings.runtime.activatingProvider : t.settings.runtime.activateProvider}
</Button>
</>
) : (
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
Set up {selectedProviderRow?.name ?? 'provider'}
{t.settings.runtime.setupProvider(selectedProviderRow?.name ?? t.settings.runtime.provider)}
</Button>
)
) : (
@@ -1085,15 +1092,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
</section>
{moa && currentMoaPreset && (
<section>
<SectionHeading icon={Cpu} title="Mixture of Agents" />
<p className="mb-2 text-xs text-muted-foreground">
Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the
acting model.
</p>
<SectionHeading icon={Cpu} title={t.settings.runtime.moa.title} />
<p className="mb-2 text-xs text-muted-foreground">{t.settings.runtime.moa.description}</p>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Select onValueChange={setSelectedMoaPreset} value={selectedMoaPreset || moa.default_preset}>
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
<SelectValue placeholder="Preset" />
<SelectValue placeholder={t.settings.runtime.moa.preset} />
</SelectTrigger>
<SelectContent>
{Object.keys(moa.presets).map(name => (
@@ -1104,7 +1108,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
</SelectContent>
</Select>
<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
checked={currentMoaPreset.enabled !== false}
disabled={applying}
@@ -1125,7 +1129,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="text"
>
Set default
{t.settings.runtime.moa.setDefault}
</Button>
<Button
disabled={Object.keys(moa.presets).length <= 1 || applying}
@@ -1151,12 +1155,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="ghost"
>
Delete
{t.common.delete}
</Button>
<Input
className={cn('w-40', CONTROL_TEXT)}
onChange={event => setNewMoaPresetName(event.target.value)}
placeholder="new preset"
placeholder={t.settings.runtime.moa.newPreset}
value={newMoaPresetName}
/>
<Button
@@ -1179,18 +1183,18 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="textStrong"
>
Add preset
{t.settings.runtime.moa.addPreset}
</Button>
</div>
<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 className="grid gap-1">
{currentMoaPreset.reference_models.map((slot, index) => (
<ListRow
action={
<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}
disabled={applying}
onCheckedChange={checked =>
@@ -1267,7 +1271,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="ghost"
>
Remove
{t.common.remove}
</Button>
</div>
}
@@ -1278,7 +1282,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
</span>
}
key={`${selectedMoaPreset}-${index}`}
title={`Reference ${index + 1}`}
title={t.settings.runtime.moa.reference(index + 1)}
/>
))}
<Button
@@ -1292,7 +1296,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="textStrong"
>
Add reference model
{t.settings.runtime.moa.addReference}
</Button>
<ListRow
below={
@@ -1354,7 +1358,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
{currentMoaPreset.aggregator.provider} · {currentMoaPreset.aggregator.model}
</span>
}
title="Aggregator"
title={t.settings.runtime.moa.aggregator}
/>
</div>
</section>
@@ -1,7 +1,9 @@
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 { renderWithEnglish as render } from './test-locale'
const { requestGateway, getProfiles } = vi.hoisted(() => ({
requestGateway: vi.fn(),
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 { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { getProfiles } from '@/hermes'
import { useI18n } from '@/i18n'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
import { normalize } from '@/lib/text'
@@ -48,6 +48,9 @@ function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
const pluginText = (key: keyof Translations['settings']['runtime']['plugins']) =>
translateNow(`settings.runtime.plugins.${key}`)
async function revealPluginsDir() {
try {
// 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?.()
if (!dir) {
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
notifyError(pluginText('unavailable'), pluginText('resolveFailed'))
return
}
@@ -65,10 +68,10 @@ async function revealPluginsDir() {
const result = await window.hermesDesktop?.openDir?.(dir)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
notifyError(result.error ?? pluginText('unknownError'), pluginText('openFailed'))
}
} 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()
if (!home) {
notifyError('The backend did not report its home directory', 'Could not open the plugins folder')
notifyError(pluginText('noHome'), pluginText('openFailed'))
return
}
@@ -90,10 +93,10 @@ async function revealAgentPluginsDir(request: GatewayRequest) {
const opened = await window.hermesDesktop?.openDir?.(`${home}/plugins`)
if (opened && !opened.ok) {
notifyError(opened.error ?? 'unknown error', 'Could not open the plugins folder')
notifyError(opened.error ?? pluginText('unknownError'), pluginText('openFailed'))
}
} 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 { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { $poolLimits, loadPoolLimits, savePoolLimits } from '@/store/pool-limits'
// 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
* changes apply live main evicts/reaps to converge without a restart. */
export function PoolLimitsSetting() {
const { t } = useI18n()
const copy = t.settings.runtime.pool
const limits = useStore($poolLimits)
const [maxDraft, setMaxDraft] = useState(String(limits.maxBackends))
const [idleDraft, setIdleDraft] = useState(String(limits.idleMs))
@@ -63,7 +66,7 @@ export function PoolLimitsSetting() {
action={
<div className="flex items-center gap-2">
<Input
aria-label="Warm bot backends"
aria-label={copy.warm}
className="w-20"
inputMode="numeric"
max={MAX_BACKENDS_MAX}
@@ -80,14 +83,14 @@ export function PoolLimitsSetting() {
/>
</div>
}
description="How many bot backends stay running for instant switching. Higher = faster switches, more memory (~60MB per backend). Applies immediately."
title="Warm Bot Backends"
description={copy.warmHint}
title={copy.warm}
/>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label="Backend idle timeout in milliseconds"
aria-label={copy.idleAria}
className="w-28"
inputMode="numeric"
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>
</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."
title="Backend Idle Timeout"
description={copy.idleHint}
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,6 +6,8 @@ import { ConfirmHost } from '@/components/confirm-host'
import { $confirmRequest } from '@/store/confirm'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { renderWithEnglish as render } from './test-locale'
const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = 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 { CustomEndpointsSettings } from './custom-endpoints-settings'
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 { SettingsContent, SettingsSkeleton } from './primitives'
@@ -346,7 +346,7 @@ export function ProvidersSettings({
onViewChange,
view
}: ProvidersSettingsProps) {
const { t } = useI18n()
const { t, locale } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
@@ -466,9 +466,15 @@ export function ProvidersSettings({
const visibleGroups = q
? 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
@@ -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 { stubResizeObserver } from '@/test/jsdom'
@@ -6,6 +6,7 @@ import type { ConfigFieldSchema } from '@/types/hermes'
import { ConfigField } from './config-field'
import { rankSearchOption, SearchableSelect } from './searchable-select'
import { renderWithEnglish as render } from './test-locale'
beforeAll(() => {
stubResizeObserver()
@@ -4,6 +4,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { controlVariants } from '@/components/ui/control'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/**
@@ -42,8 +43,8 @@ export function SearchableSelect({
value,
onChange,
options,
placeholder = 'Search…',
emptyMessage = 'No results found.',
placeholder,
emptyMessage,
clearLabel
}: {
value: string
@@ -55,6 +56,8 @@ export function SearchableSelect({
* Matches the existing <Select> pattern of EMPTY_SELECT_VALUE + "(none)". */
clearLabel?: string
}) {
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t.settings.runtime.search
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -66,7 +69,7 @@ export function SearchableSelect({
[onChange]
)
const displayValue = value !== '' && value !== undefined ? value : placeholder
const displayValue = value !== '' && value !== undefined ? value : searchPlaceholder
return (
<Popover onOpenChange={setOpen} open={open}>
@@ -90,9 +93,9 @@ export function SearchableSelect({
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command filter={rankSearchOption}>
<CommandInput autoFocus placeholder={placeholder} />
<CommandInput autoFocus placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandEmpty>{emptyMessage ?? t.settings.runtime.noResults}</CommandEmpty>
<CommandGroup>
{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 type { TerminalBackendsResponse } from '@/types/hermes'
import { renderWithEnglish as render } from './test-locale'
const getTerminalBackends = 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 { 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 { MemoryRouter } 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 { renderWithEnglish as rtlRender } from './test-locale'
// EnvVarField navigates to Settings → Keys via useNavigate, so every render
// needs a router context. The navigate spy asserts the deep-link target.
const navigateSpy = vi.fn()
@@ -103,7 +103,7 @@ describe('the catalog owns model curation', () => {
renderMenu()
await screen.findByText(/Gemini 2\.5 Flash/i)
const input = screen.getByRole('textbox', { name: 'Search models' })
const input = screen.getByRole('textbox', { name: /Search models|Model ara/ })
fireEvent.change(input, { target: { value: 'gemini-3.1' } })
@@ -116,7 +116,7 @@ describe('the catalog owns model curation', () => {
renderMenu()
await screen.findByText(/Gemini 3\.1 Pro/i)
fireEvent.click(screen.getByText('Edit models…'))
fireEvent.click(screen.getByText(/Edit models…|Modelleri düzenle…/))
expect($modelVisibilityOpen.get()).toBe(true)
})
@@ -626,7 +626,7 @@ export function ModelCatalogMenu({
{shownMoaPresets.length > 0 ? (
<div className={cn(quietRows)}>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{t.surfaces.moaPresets}</DropdownMenuLabel>
{shownMoaPresets.map(preset => {
const isCurrentMoa = current.provider === 'moa' && current.model === preset
+5 -5
View File
@@ -27,7 +27,7 @@ import {
setSkillEnabled,
setToolsetEnabled
} from '@/hermes'
import { useI18n } from '@/i18n'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
import { compactNumber } from '@/lib/format'
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'
// 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 provenance = skill.provenance
@@ -127,12 +127,12 @@ function skillSubtitle(skill: SkillInfo): React.ReactNode {
<span className="truncate">{category}</span>
{provenance === 'agent' && (
<Badge className="shrink-0 normal-case" variant="default">
learned
{copy.learned}
</Badge>
)}
{provenance === 'hub' && (
<Badge className="shrink-0 normal-case" variant="muted">
hub
{copy.hub}
</Badge>
)}
</>
@@ -927,7 +927,7 @@ export function SkillsView({
setSelectedOfficial(null)
}}
onToggle={enabled => void handleToggleSkill(skill, enabled)}
subtitle={skillSubtitle(skill)}
subtitle={skillSubtitle(skill, t.surfaces)}
title={skill.name}
toggleLabel={skill.name}
/>
+1 -1
View File
@@ -1621,7 +1621,7 @@ function McpCatalog({
</span>
<CatalogTag>{entry.transport}</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.installed && (
<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 { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
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). */
export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) {
const { t } = useI18n()
const [editing, setEditing] = useState<EditState | null>(null)
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
const [loading, setLoading] = useState(false)
@@ -125,7 +127,7 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
onClick={() => void openEdit()}
type="button"
>
Edit {noun}
{target.kind === 'skill' ? t.surfaces.editSkill : t.surfaces.editMemory}
</button>
<button
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"
>
{target.kind === 'skill' ? 'Archive skill' : 'Delete memory'}
{target.kind === 'skill' ? t.surfaces.archiveSkill : t.surfaces.deleteMemory}
</button>
</div>
</>
@@ -144,7 +146,7 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
<Dialog onOpenChange={value => !value && !saving && setEditing(null)} open={Boolean(editing)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {editing?.label}</DialogTitle>
<DialogTitle>{t.surfaces.editNode(editing?.label ?? '')}</DialogTitle>
</DialogHeader>
<div className="h-80">
{editing && (
@@ -162,10 +164,10 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<DialogFooter>
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button disabled={saving} onClick={() => void save()}>
{saving ? 'Saving…' : 'Save'}
{saving ? t.common.saving : t.common.save}
</Button>
</DialogFooter>
</DialogContent>
@@ -186,8 +188,8 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
/>
) : (
<ConfirmDialog
confirmLabel="Delete"
description="This memory is removed permanently."
confirmLabel={t.common.delete}
description={t.surfaces.permanentMemory}
destructive
dismissOnConfirm
onClose={() => setDeleting(null)}
@@ -211,7 +213,7 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
)
}}
open={Boolean(deleting)}
title={`Delete ${deleting?.label ?? ''}?`}
title={t.surfaces.deleteNode(deleting?.label ?? '')}
/>
)}
</>
@@ -1,8 +1,10 @@
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 { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
import { decodeShareCode, encodeShareCode, ShareCodeError, shareCodeErrorText } from './share-code'
function sampleGraph(): StarmapGraph {
return {
@@ -154,6 +156,36 @@ describe('share-code', () => {
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', () => {
const code = encodeShareCode(sampleGraph())
// 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 { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes'
@@ -168,6 +169,13 @@ function readGraph(r: BitReader): StarmapGraph {
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>({
error: ShareCodeError,
noun: 'map code',
@@ -1,6 +1,10 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
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'
describe('ShareControls', () => {
@@ -8,17 +12,35 @@ describe('ShareControls', () => {
cleanup()
})
it('opens its dialog when the trigger has a tooltip', async () => {
render(<ShareControls shareCode="map-code" />)
it.each([
['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' })
expect((await screen.findByRole('tooltip')).textContent).toContain('Import / export map')
expect((await screen.findByRole('tooltip')).textContent).toContain(title)
fireEvent.click(trigger)
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 !!!')
})
})
+9 -6
View File
@@ -3,6 +3,7 @@ import { atom, type WritableAtom } from 'nanostores'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
import { useI18n } from '@/i18n'
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
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 { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
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 { buildSimulation } from './simulation'
import { formatDate } from './text'
@@ -106,6 +107,7 @@ export function StarMap({
onImport?: (graph: StarmapGraph) => void
onResetMap?: () => void
}) {
const { t } = useI18n()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const wrapRef = useRef<HTMLDivElement | null>(null)
@@ -196,10 +198,10 @@ export function StarMap({
return null
} 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.
@@ -967,12 +969,13 @@ export function StarMap({
{/* 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">
<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 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 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} />
</div>
</div>
+4 -2
View File
@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import type { TimeAxis } from './time-axis'
@@ -109,6 +110,7 @@ export const Timeline = memo(function Timeline({
revealStore,
ringStops = []
}: TimelineProps) {
const { t } = useI18n()
const trackRef = useRef<HTMLDivElement | null>(null)
const draggingRef = useRef(false)
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>
<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"
onClick={onTogglePlay}
type="button"
@@ -194,7 +196,7 @@ export const Timeline = memo(function Timeline({
</button>
<div
aria-label="Timeline scrubber"
aria-label={t.surfaces.timeline}
aria-valuemax={100}
aria-valuemin={0}
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 })
})
+18 -45
View File
@@ -1,6 +1,7 @@
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 { $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.
// 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', 'balls in your court', 'awaiting orders'],
tone: 'wait'
}
}
const TONE_COLOR: Record<Tone, string> = {
error: 'var(--ui-red)',
@@ -89,6 +50,18 @@ function pick(lines: string[], prev: string): string {
}
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 activity = useStore($petActivity)
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
// it's actually the user's turn. Everything else maps to a mood spec.
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'
// Pick a fresh line on every mood change, then keep rotating (random, no
// repeat) only while the agent is actively working/thinking.
useEffect(() => {
const spec = specKey ? SPECS[specKey] : null
const spec = specKey ? specs[specKey] : null
if (!spec) {
setLine('')
@@ -120,9 +93,9 @@ export function PetBubble() {
const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600)
return () => window.clearInterval(id)
}, [specKey, rotating])
}, [specKey, rotating, specs])
const spec = specKey ? SPECS[specKey] : null
const spec = specKey ? specs[specKey] : null
if (!spec) {
return null
+1
View File
@@ -17,6 +17,7 @@ export {}
declare global {
interface Window {
hermesDesktop: {
setUiLanguage?: (locale: string) => void
// Resolve a backend connection. Omit `profile` (or pass the primary) for
// the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool.
+2 -2
View File
@@ -25,7 +25,7 @@ export function aiturkInstallStage(name: string): string | undefined {
const en = {
intro: 'Bring your code, question, or idea. Build with AITURK.',
pitch: 'Connect your models with your TurkServis account.',
pitch: 'Connect your models and available image and audio tools with your TurkServis account.',
key: 'TurkServis API key',
getKey: 'My account and API keys',
noModels: 'No models are available for this key.',
@@ -74,7 +74,7 @@ const en = {
const tr: typeof en = {
intro: 'Kodunuzu, sorunuzu veya fikrinizi paylaşın. AITURK ile birlikte geliştirin.',
pitch: 'TurkServis hesabınızla modellerinize bağlanın.',
pitch: 'TurkServis hesabınızla modellerinize, kullanılabilir görsel ve ses araçlarına bağlanın.',
key: 'TurkServis API anahtarı',
getKey: 'Hesabım ve API anahtarlarım',
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
+48 -12
View File
@@ -30,15 +30,51 @@ describe('I18nProvider', () => {
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(
<I18nProvider configClient={null}>
<LanguageProbe />
</I18nProvider>
)
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
expect(screen.getByTestId('locale').textContent).toBe('tr')
expect(screen.getByTestId('label').textContent).toBe('Dil')
})
it('normalizes an initial locale alias and switches translations', async () => {
@@ -76,7 +112,7 @@ describe('I18nProvider', () => {
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 = {
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
saveConfig: vi.fn()
@@ -90,8 +126,8 @@ describe('I18nProvider', () => {
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
expect(screen.getByTestId('locale').textContent).toBe('tr')
expect(screen.getByTestId('label').textContent).toBe('Dil')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
@@ -147,8 +183,8 @@ describe('I18nProvider', () => {
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
expect(screen.getByTestId('locale').textContent).toBe('tr')
expect(screen.getByTestId('label').textContent).toBe('Dil')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
@@ -217,14 +253,14 @@ describe('I18nProvider', () => {
)
expect(screen.getByTestId('locale').textContent).toBe('ar')
expect(document.documentElement.dir).toBe('rtl')
expect(document.documentElement.lang).toBe('ar')
expect(window.document.documentElement.dir).toBe('rtl')
expect(window.document.documentElement.lang).toBe('ar')
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
expect(document.documentElement.dir).toBe('ltr')
expect(document.documentElement.lang).toBe('en')
expect(window.document.documentElement.dir).toBe('ltr')
expect(window.document.documentElement.lang).toBe('en')
})
it('rolls back the visible locale when saving fails', async () => {
+4
View File
@@ -105,6 +105,10 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini
localeRef.current = locale
setRuntimeI18nLocale(locale)
applyDocumentLocale(locale)
if (typeof window !== 'undefined') {
window.hermesDesktop?.setUiLanguage?.(locale)
}
}, [locale])
useEffect(() => {
+168
View File
@@ -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 CuaDrivers 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.'
}
+109
View File
@@ -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', 'balls 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.'
}
}
+5
View File
@@ -1,8 +1,11 @@
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'
export const en: Translations = {
surfaces: EN_SURFACES,
common: {
apply: 'Apply',
back: 'Back',
@@ -383,6 +386,7 @@ export const en: Translations = {
},
settings: {
runtime: EN_RUNTIME_SETTINGS,
closeSettings: 'Close settings',
exportConfig: 'Export config',
importConfig: 'Import config',
@@ -514,6 +518,7 @@ export const en: Translations = {
completionSoundPreview: 'Preview'
},
sections: {
browser: 'Browser',
model: 'Model',
chat: 'Chat',
appearance: 'Appearance',

Some files were not shown because too many files have changed in this diff Show More