feat(web): replace native language selects with custom dropdown and fix RTL text alignment

This commit modernizes the language selector UI across the ZeroClaw web dashboard by replacing
native <select> elements with a shared custom dropdown component featuring styled flag icons,
proper RTL/LTR text direction support, and consistent left-aligned text for all languages.

Changes:
- Add LanguageSelector component with custom dropdown, flag badges, and check mark for selected option
- Wire document direction updates on locale change for Arabic, Hebrew, Urdu, and other RTL languages
- Fix RTL text alignment in dropdown options by applying dir attribute only to text spans
- Update pairing dialog and authenticated header to use the shared LanguageSelector
- Add locale metadata helpers: getLanguageOption, getLanguageOptionLabel, getLocaleDirection, applyLocaleToDocument
- Add Vitest configuration and unit tests for i18n helpers
- Add Playwright E2E tests verifying all 31 locales with flag visibility and lang/dir attributes

Testing:
- Unit tests: 5 passed (npm run test:unit)
- Build: passed (npm run build)
- E2E tests: 34 passed (npx playwright test)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
argenis de la rosa 2026-03-09 13:50:21 -04:00
parent c56c66a21d
commit 3f5c57634b
29 changed files with 4190 additions and 873 deletions

4
web/dist/index.html vendored
View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>ZeroClaw</title>
<script type="module" crossorigin src="/_app/assets/index-Dam-egf7.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-DEhGL4Jw.css">
<script type="module" crossorigin src="/_app/assets/index-D6zI06t7.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-BdvEg_j9.css">
</head>
<body>
<div id="root"></div>

60
web/e2e/dashboard.spec.ts Normal file
View File

@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('zeroclaw_token', 'test-token');
window.localStorage.setItem('zeroclaw:locale', 'en');
});
});
test('dashboard end-to-end flow stays healthy', async ({ page }) => {
await page.goto('/_app/');
await expect(page.getByText('Electric Runtime Dashboard')).toBeVisible();
await page.goto('/_app/agent');
await expect(page.getByPlaceholder('Type a message...')).toBeVisible();
await page.getByTestId('chat-input').fill('hello from e2e');
await page.keyboard.press('Enter');
await expect(page.getByText('Echo: hello from e2e')).toBeVisible();
await page.goto('/_app/tools');
await page.getByRole('button', { name: /shell/i }).first().click();
await expect(page.getByText('Parameter Schema')).toBeVisible();
await page.goto('/_app/cron');
await page.getByRole('button', { name: 'Add Job' }).click();
await page.getByPlaceholder('e.g. Daily cleanup').fill('Morning job');
await page.getByPlaceholder('e.g. 0 0 * * * (cron expression)').fill('0 8 * * *');
await page.getByPlaceholder('e.g. cleanup --older-than 7d').fill('sync --morning');
await page.getByRole('button', { name: 'Add Job' }).last().click();
await expect(page.getByText('Morning job')).toBeVisible();
await page.goto('/_app/integrations');
await expect(page.getByText('Discord')).toBeVisible();
await page.goto('/_app/memory');
await page.getByRole('button', { name: 'Add Memory' }).click();
await page.getByPlaceholder('e.g. user_preferences').fill('favorite_editor');
await page.getByPlaceholder('Memory content...').fill('neovim');
await page.getByPlaceholder('e.g. preferences, context, facts').fill('preferences');
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.getByText('favorite_editor')).toBeVisible();
await page.goto('/_app/config');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Configuration saved successfully.')).toBeVisible();
await page.goto('/_app/cost');
await expect(page.getByText('Token Statistics')).toBeVisible();
await expect(page.getByText('anthropic/claude-sonnet-4.6')).toBeVisible();
await page.goto('/_app/logs');
await expect(page.getByText('Scheduler heartbeat ok.', { exact: false })).toBeVisible();
await page.getByRole('button', { name: 'Pause' }).click();
await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible();
await page.goto('/_app/doctor');
await page.getByRole('button', { name: 'Run Diagnostics' }).click();
await expect(page.getByText('Configuration looks healthy.')).toBeVisible();
await expect(page.getByText('Webhook endpoint is not configured.')).toBeVisible();
});

118
web/e2e/locales.spec.ts Normal file
View File

@ -0,0 +1,118 @@
import { expect, test, type Page } from '@playwright/test';
import { getLanguageOption, getLocaleDirection, LANGUAGE_OPTIONS, LANGUAGE_SWITCH_ORDER, tLocale, type Locale } from '../src/lib/i18n';
const routes: Array<{
path: string;
titleKey: string;
assertion?: (page: Page, locale: Locale) => Promise<void>;
}> = [
{
path: '/_app/',
titleKey: 'nav.dashboard',
assertion: (page, locale) => expect(page.getByText(tLocale('dashboard.hero_title', locale), { exact: false })).toBeVisible(),
},
{
path: '/_app/agent',
titleKey: 'nav.agent',
assertion: (page, locale) => expect(page.getByPlaceholder(tLocale('agent.placeholder', locale))).toBeVisible(),
},
{
path: '/_app/tools',
titleKey: 'nav.tools',
assertion: (page, locale) => expect(page.getByPlaceholder(tLocale('tools.search', locale))).toBeVisible(),
},
{
path: '/_app/cron',
titleKey: 'nav.cron',
assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('cron.add', locale) })).toBeVisible(),
},
{ path: '/_app/integrations', titleKey: 'nav.integrations' },
{
path: '/_app/memory',
titleKey: 'nav.memory',
assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('memory.add_memory', locale) })).toBeVisible(),
},
{
path: '/_app/config',
titleKey: 'nav.config',
assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('config.save', locale) })).toBeVisible(),
},
{
path: '/_app/cost',
titleKey: 'nav.cost',
assertion: (page, locale) => expect(page.getByText(tLocale('cost.token_statistics', locale), { exact: false })).toBeVisible(),
},
{
path: '/_app/logs',
titleKey: 'nav.logs',
assertion: (page, locale) => expect(page.getByText(tLocale('logs.title', locale), { exact: false })).toBeVisible(),
},
{
path: '/_app/doctor',
titleKey: 'nav.doctor',
assertion: (page, locale) => expect(page.getByRole('heading', { name: tLocale('doctor.title', locale) }).first()).toBeVisible(),
},
];
async function chooseLocale(page: Page, locale: Locale) {
if (await page.locator('html').getAttribute('lang') === locale) {
await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag);
return;
}
await page.getByTestId('locale-select').click();
await expect(page.getByTestId('locale-menu')).toBeVisible();
await page.getByTestId(`locale-option-${locale}`).click();
await expect(page.getByTestId('locale-menu')).toBeHidden();
}
test.describe('localization', () => {
test('locale selector lists every configured language', async ({ page }) => {
await page.goto('/_app/');
await page.getByTestId('locale-select').click();
await expect(page.getByTestId('locale-menu')).toBeVisible();
for (const option of LANGUAGE_OPTIONS) {
const optionLocator = page.getByTestId(`locale-option-${option.value}`);
await expect(optionLocator).toContainText(option.flag);
await expect(optionLocator).toContainText(option.label);
}
});
test('pairing screen supports every locale', async ({ page }) => {
await page.goto('/_app/');
for (const locale of LANGUAGE_SWITCH_ORDER) {
await chooseLocale(page, locale);
await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag);
await expect(page.locator('html')).toHaveAttribute('lang', locale);
await expect(page.locator('html')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.locator('body')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.getByTestId('pair-button')).toHaveText(tLocale('auth.pair_button', locale));
await expect(page.getByPlaceholder(tLocale('auth.code_placeholder', locale))).toBeVisible();
await expect(page.getByText(tLocale('auth.enter_code', locale))).toBeVisible();
}
});
for (const locale of LANGUAGE_SWITCH_ORDER) {
test(`authenticated dashboard shell localizes cleanly for ${locale}`, async ({ page }) => {
await page.addInitScript((selectedLocale) => {
window.localStorage.setItem('zeroclaw_token', 'test-token');
window.localStorage.setItem('zeroclaw:locale', selectedLocale);
}, locale);
for (const route of routes) {
await page.goto(route.path);
await chooseLocale(page, locale);
await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag);
await expect(page.locator('html')).toHaveAttribute('lang', locale);
await expect(page.locator('html')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.locator('body')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.locator('header h1')).toHaveText(tLocale(route.titleKey, locale));
if (route.assertion) {
await route.assertion(page, locale);
}
}
});
}
});

353
web/e2e/mock-server.mjs Normal file
View File

@ -0,0 +1,353 @@
import http from 'node:http';
import { WebSocketServer } from 'ws';
const PORT = Number(process.env.PW_API_PORT ?? '4174');
const VALID_TOKEN = 'test-token';
const VALID_CODE = '123456';
let cronJobs = [
{
id: 'job-alpha',
name: 'nightly sync',
command: 'sync --nightly',
next_run: '2026-03-10T00:00:00.000Z',
last_run: '2026-03-09T00:00:00.000Z',
last_status: 'ok',
enabled: true,
},
];
let memoryEntries = [
{
id: 'memory-1',
key: 'workspace_mode',
content: 'Dashboard E2E workspace is active.',
category: 'context',
timestamp: '2026-03-09T12:00:00.000Z',
session_id: 'session-1',
score: 0.99,
},
];
const healthSnapshot = {
pid: 4242,
updated_at: '2026-03-09T12:00:00.000Z',
uptime_seconds: 12345,
components: {
gateway: {
status: 'ok',
updated_at: '2026-03-09T12:00:00.000Z',
last_ok: '2026-03-09T12:00:00.000Z',
last_error: null,
restart_count: 0,
},
scheduler: {
status: 'ok',
updated_at: '2026-03-09T12:00:00.000Z',
last_ok: '2026-03-09T12:00:00.000Z',
last_error: null,
restart_count: 1,
},
},
};
function sendJson(res, statusCode, body) {
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(body));
}
function unauthorized(res) {
sendJson(res, 401, { error: 'Unauthorized' });
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => {
data += chunk;
});
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
function isAuthorized(req) {
const authHeader = req.headers.authorization;
if (authHeader === `Bearer ${VALID_TOKEN}`) {
return true;
}
const url = new URL(req.url, `http://${req.headers.host}`);
return url.searchParams.get('token') === VALID_TOKEN;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/health') {
return sendJson(res, 200, { require_pairing: true, paired: false });
}
if (req.method === 'POST' && url.pathname === '/pair') {
if (req.headers['x-pairing-code'] !== VALID_CODE) {
return sendJson(res, 400, { error: 'Invalid pairing code' });
}
return sendJson(res, 200, { token: VALID_TOKEN });
}
if (url.pathname.startsWith('/api') && !isAuthorized(req)) {
return unauthorized(res);
}
if (req.method === 'GET' && url.pathname === '/api/status') {
return sendJson(res, 200, {
provider: 'openrouter',
model: 'anthropic/claude-sonnet-4.6',
temperature: 0.7,
uptime_seconds: 12345,
gateway_port: PORT,
locale: 'en',
memory_backend: 'sqlite',
paired: true,
channels: {
discord: true,
telegram: false,
slack: false,
},
health: healthSnapshot,
});
}
if (req.method === 'GET' && url.pathname === '/api/health') {
return sendJson(res, 200, { health: healthSnapshot });
}
if (req.method === 'GET' && url.pathname === '/api/config') {
return sendJson(res, 200, {
format: 'toml',
content: [
'default_provider = "openrouter"',
'default_model = "anthropic/claude-sonnet-4.6"',
'',
'[gateway]',
`port = ${PORT}`,
].join('\n'),
});
}
if (req.method === 'PUT' && url.pathname === '/api/config') {
await readBody(req);
return sendJson(res, 200, { status: 'ok' });
}
if (req.method === 'GET' && url.pathname === '/api/tools') {
return sendJson(res, 200, {
tools: [
{
name: 'shell',
description: 'Execute shell commands in the active workspace.',
parameters: { type: 'object', properties: { command: { type: 'string' } } },
},
{
name: 'memory_store',
description: 'Persist a memory entry.',
parameters: { type: 'object', properties: { key: { type: 'string' } } },
},
],
});
}
if (req.method === 'GET' && url.pathname === '/api/cli-tools') {
return sendJson(res, 200, {
cli_tools: [
{ name: 'git', path: '/usr/bin/git', version: '2.39.0', category: 'vcs' },
{ name: 'cargo', path: '/usr/bin/cargo', version: '1.86.0', category: 'rust' },
],
});
}
if (req.method === 'GET' && url.pathname === '/api/cron') {
return sendJson(res, 200, { jobs: cronJobs });
}
if (req.method === 'POST' && url.pathname === '/api/cron') {
const raw = await readBody(req);
const body = JSON.parse(raw || '{}');
const job = {
id: `job-${Date.now()}`,
name: body.name ?? null,
command: body.command,
next_run: '2026-03-11T00:00:00.000Z',
last_run: null,
last_status: null,
enabled: body.enabled ?? true,
};
cronJobs = [...cronJobs, job];
return sendJson(res, 200, { status: 'ok', job });
}
if (req.method === 'DELETE' && url.pathname.startsWith('/api/cron/')) {
const id = decodeURIComponent(url.pathname.split('/').pop());
cronJobs = cronJobs.filter((job) => job.id !== id);
res.writeHead(204);
return res.end();
}
if (req.method === 'GET' && url.pathname === '/api/integrations') {
return sendJson(res, 200, {
integrations: [
{
name: 'Discord',
description: 'Send notifications and respond in channels.',
category: 'chat',
status: 'Active',
},
{
name: 'GitHub',
description: 'Track pull requests and issues.',
category: 'devops',
status: 'Available',
},
{
name: 'Linear',
description: 'Sync roadmap items and tasks.',
category: 'project',
status: 'ComingSoon',
},
],
});
}
if (req.method === 'POST' && url.pathname === '/api/doctor') {
return sendJson(res, 200, {
results: [
{ severity: 'ok', category: 'config', message: 'Configuration looks healthy.' },
{ severity: 'warn', category: 'network', message: 'Webhook endpoint is not configured.' },
],
});
}
if (req.method === 'GET' && url.pathname === '/api/memory') {
const query = url.searchParams.get('query')?.toLowerCase() ?? '';
const category = url.searchParams.get('category') ?? '';
const entries = memoryEntries.filter((entry) => {
const matchesQuery = !query
|| entry.key.toLowerCase().includes(query)
|| entry.content.toLowerCase().includes(query);
const matchesCategory = !category || entry.category === category;
return matchesQuery && matchesCategory;
});
return sendJson(res, 200, { entries });
}
if (req.method === 'POST' && url.pathname === '/api/memory') {
const raw = await readBody(req);
const body = JSON.parse(raw || '{}');
const entry = {
id: `memory-${Date.now()}`,
key: body.key,
content: body.content,
category: body.category || 'notes',
timestamp: new Date().toISOString(),
session_id: 'session-e2e',
score: 1,
};
memoryEntries = [entry, ...memoryEntries];
return sendJson(res, 200, { status: 'ok' });
}
if (req.method === 'DELETE' && url.pathname.startsWith('/api/memory/')) {
const key = decodeURIComponent(url.pathname.split('/').pop());
memoryEntries = memoryEntries.filter((entry) => entry.key !== key);
res.writeHead(204);
return res.end();
}
if (req.method === 'GET' && url.pathname === '/api/cost') {
return sendJson(res, 200, {
cost: {
session_cost_usd: 0.0132,
daily_cost_usd: 0.1024,
monthly_cost_usd: 2.3811,
total_tokens: 48231,
request_count: 128,
by_model: {
'anthropic/claude-sonnet-4.6': {
model: 'anthropic/claude-sonnet-4.6',
cost_usd: 1.8123,
total_tokens: 35123,
request_count: 84,
},
'openai/gpt-4o-mini': {
model: 'openai/gpt-4o-mini',
cost_usd: 0.5688,
total_tokens: 13108,
request_count: 44,
},
},
},
});
}
if (req.method === 'GET' && url.pathname === '/api/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const push = (payload) => {
res.write(`event: ${payload.type}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
push({ type: 'status', timestamp: new Date().toISOString(), message: 'Event stream connected.' });
const interval = setInterval(() => {
push({ type: 'health', timestamp: new Date().toISOString(), message: 'Scheduler heartbeat ok.' });
}, 750);
req.on('close', () => {
clearInterval(interval);
res.end();
});
return;
}
sendJson(res, 404, { error: 'Not found' });
});
const wsServer = new WebSocketServer({ noServer: true });
wsServer.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'message', content: 'Connected to mock ZeroClaw runtime.' }));
socket.on('message', (raw) => {
const message = JSON.parse(String(raw));
if (message.type !== 'message') {
return;
}
socket.send(JSON.stringify({ type: 'chunk', content: 'Echo: ' }));
socket.send(JSON.stringify({ type: 'done', content: `Echo: ${message.content}` }));
});
});
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== '/ws/chat' || !isAuthorized(req)) {
socket.write('HTTP/1.1 401 Unauthorized\\r\\n\\r\\n');
socket.destroy();
return;
}
wsServer.handleUpgrade(req, socket, head, (client) => {
wsServer.emit('connection', client, req);
});
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`Mock backend listening on http://127.0.0.1:${PORT}`);
});

435
web/package-lock.json generated
View File

@ -7,6 +7,7 @@
"": {
"name": "zeroclaw-web",
"version": "0.1.0",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.0.0",
@ -14,6 +15,7 @@
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.3.0",
"@types/react": "^19.0.7",
@ -21,7 +23,9 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^4.0.18",
"ws": "^8.19.0"
}
},
"node_modules/@babel/code-frame": {
@ -798,6 +802,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1155,6 +1175,13 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
@ -1472,6 +1499,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1530,6 +1575,127 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@ -1598,6 +1764,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1674,6 +1850,13 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@ -1726,6 +1909,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2142,6 +2345,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2162,6 +2383,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -2327,6 +2595,13 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2337,6 +2612,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz",
@ -2358,6 +2647,23 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -2375,6 +2681,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@ -2502,6 +2818,123 @@
}
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -7,7 +7,10 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:unit": "vitest run --config vitest.config.ts"
},
"dependencies": {
"lucide-react": "^0.468.0",
@ -16,6 +19,7 @@
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.3.0",
"@types/react": "^19.0.7",
@ -23,6 +27,8 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^4.0.18",
"ws": "^8.19.0"
}
}

34
web/playwright.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const FRONTEND_PORT = Number(process.env.PW_WEB_PORT ?? '4173');
const BACKEND_PORT = Number(process.env.PW_API_PORT ?? '4174');
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: {
timeout: 10_000,
},
use: {
baseURL: `http://127.0.0.1:${FRONTEND_PORT}`,
headless: true,
trace: 'on-first-retry',
},
webServer: [
{
command: `node e2e/mock-server.mjs`,
url: `http://127.0.0.1:${BACKEND_PORT}/health`,
reuseExistingServer: true,
cwd: rootDir,
},
{
command: `VITE_API_TARGET=http://127.0.0.1:${BACKEND_PORT} VITE_WS_TARGET=ws://127.0.0.1:${BACKEND_PORT} npm run dev -- --host 127.0.0.1 --port ${FRONTEND_PORT}`,
url: `http://127.0.0.1:${FRONTEND_PORT}/_app/`,
reuseExistingServer: true,
cwd: rootDir,
},
],
});

View File

@ -12,27 +12,48 @@ import Cost from './pages/Cost';
import Logs from './pages/Logs';
import Doctor from './pages/Doctor';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { setLocale, type Locale } from './lib/i18n';
import {
applyLocaleToDocument,
coerceLocale,
getLocaleDirection,
setLocale,
tLocale,
type Locale,
} from './lib/i18n';
import LanguageSelector from './components/controls/LanguageSelector';
const LOCALE_STORAGE_KEY = 'zeroclaw:locale';
// Locale context
interface LocaleContextType {
locale: string;
setAppLocale: (locale: string) => void;
locale: Locale;
setAppLocale: (locale: Locale) => void;
}
export const LocaleContext = createContext<LocaleContextType>({
locale: 'tr',
setAppLocale: () => {},
locale: 'en',
setAppLocale: (_locale: Locale) => {},
});
export const useLocaleContext = () => useContext(LocaleContext);
// Pairing dialog component
function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> }) {
function PairingDialog({
locale,
setAppLocale,
onPair,
}: {
locale: Locale;
setAppLocale: (locale: Locale) => void;
onPair: (code: string) => Promise<void>;
}) {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const translate = (key: string) => tLocale(key, locale);
const localeDirection = getLocaleDirection(locale);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@ -40,38 +61,49 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
try {
await onPair(code);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pairing failed');
setError(err instanceof Error ? err.message : translate('auth.pairing_failed'));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="bg-gray-900 rounded-xl p-8 w-full max-w-md border border-gray-800">
<div className="pairing-shell min-h-screen flex items-center justify-center px-4" dir={localeDirection}>
<div className="pairing-card w-full max-w-md rounded-2xl p-8">
<div className="mb-4 flex justify-end">
<LanguageSelector
locale={locale}
onChange={setAppLocale}
ariaLabel={translate('common.select_language')}
title={translate('common.languages')}
align="right"
buttonClassName="inline-flex min-w-[12rem] items-center gap-2 rounded-xl border border-[#2b4f97] bg-[#091937]/75 px-3 py-2 text-sm text-[#c4d8ff] shadow-[0_0_0_1px_rgba(79,131,255,0.08)] transition hover:border-[#4f83ff] hover:text-white"
/>
</div>
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">ZeroClaw</h1>
<p className="text-gray-400">Enter the pairing code from your terminal</p>
<h1 className="mb-2 text-2xl font-semibold tracking-[0.16em] pairing-brand">ZEROCLAW</h1>
<p className="text-sm text-[#9bb8e8]">{translate('auth.enter_code')}</p>
</div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4"
placeholder={translate('auth.code_placeholder')}
className="w-full rounded-xl border border-[#29509c] bg-[#071228]/90 px-4 py-3 text-center text-2xl tracking-[0.35em] text-white focus:border-[#4f83ff] focus:outline-none mb-4"
maxLength={6}
autoFocus
/>
{error && (
<p className="text-red-400 text-sm mb-4 text-center">{error}</p>
<p className="mb-4 text-center text-sm text-rose-300">{error}</p>
)}
<button
type="submit"
disabled={loading || code.length < 6}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
data-testid="pair-button"
className="electric-button w-full rounded-xl py-3 font-medium text-white disabled:opacity-50"
>
{loading ? 'Pairing...' : 'Pair'}
{loading ? translate('auth.pairing_progress') : translate('auth.pair_button')}
</button>
</form>
</div>
@ -81,11 +113,38 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
function AppContent() {
const { isAuthenticated, loading, pair, logout } = useAuth();
const [locale, setLocaleState] = useState('tr');
const [locale, setLocaleState] = useState<Locale>(() => {
const initialLocale = (() => {
if (typeof window === 'undefined') {
return 'en';
}
const setAppLocale = (newLocale: string) => {
const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (saved) {
return coerceLocale(saved);
}
return coerceLocale(window.navigator.language);
})();
setLocale(initialLocale);
if (typeof document !== 'undefined') {
applyLocaleToDocument(initialLocale, document);
}
return initialLocale;
});
useEffect(() => {
setLocale(locale);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
applyLocaleToDocument(locale, document);
}
}, [locale]);
const setAppLocale = (newLocale: Locale) => {
setLocale(newLocale);
setLocaleState(newLocale);
setLocale(newLocale as Locale);
};
// Listen for 401 events to force logout
@ -99,19 +158,22 @@ function AppContent() {
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<p className="text-gray-400">Connecting...</p>
<div className="pairing-shell min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="electric-loader h-10 w-10 rounded-full" />
<p className="text-[#a7c4f3]">{tLocale('common.connecting', locale)}</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <PairingDialog onPair={pair} />;
return <PairingDialog locale={locale} setAppLocale={setAppLocale} onPair={pair} />;
}
return (
<LocaleContext.Provider value={{ locale, setAppLocale }}>
<Routes>
<Routes key={locale}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/agent" element={<AgentChat />} />

View File

@ -0,0 +1,129 @@
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
getLanguageOption,
getLanguageOptionLabel,
getLocaleDirection,
LANGUAGE_OPTIONS,
type Locale,
} from '@/lib/i18n';
interface LanguageSelectorProps {
locale: Locale;
onChange: (locale: Locale) => void;
ariaLabel: string;
title?: string;
align?: 'left' | 'right';
buttonClassName?: string;
menuClassName?: string;
}
export default function LanguageSelector({
locale,
onChange,
ariaLabel,
title,
align = 'right',
buttonClassName = '',
menuClassName = '',
}: LanguageSelectorProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const activeLanguage = getLanguageOption(locale);
const localeDirection = getLocaleDirection(locale);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
window.addEventListener('mousedown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mousedown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
const alignmentClass = align === 'left' ? 'left-0' : 'right-0';
return (
<div ref={containerRef} className="relative" dir={localeDirection}>
<button
id="locale-selector-trigger"
type="button"
data-testid="locale-select"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
title={title}
onClick={() => setOpen((current) => !current)}
className={buttonClassName}
>
<span
aria-hidden="true"
data-testid="locale-flag"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#0f2450] text-sm shadow-inner shadow-black/20"
>
{activeLanguage.flag}
</span>
<span dir={localeDirection} className="min-w-0 truncate text-start">{getLanguageOptionLabel(activeLanguage)}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition ${open ? 'rotate-180' : ''}`} />
</button>
{open ? (
<div
className={`absolute z-50 mt-2 w-72 max-w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-[#2b4f97] bg-[#071228]/96 shadow-[0_20px_60px_rgba(0,0,0,0.45)] backdrop-blur-xl ${alignmentClass} ${menuClassName}`}
>
<div
role="listbox"
aria-label={ariaLabel}
data-testid="locale-menu"
className="max-h-80 overflow-y-auto p-2"
>
{LANGUAGE_OPTIONS.map((option) => {
const selected = option.value === locale;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
data-testid={`locale-option-${option.value}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm transition text-start ${
selected
? 'bg-[#13305f] text-white shadow-[0_0_0_1px_rgba(79,131,255,0.24)]'
: 'text-[#c4d8ff] hover:bg-[#0d2147] hover:text-white'
}`}
>
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#0f2450] text-base shadow-inner shadow-black/20"
>
{option.flag}
</span>
<span dir={option.direction} className="min-w-0 flex-1 truncate text-start">
{getLanguageOptionLabel(option)}
</span>
{selected ? <Check className="h-4 w-4 shrink-0 text-[#8cc2ff]" /> : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useLocation } from 'react-router-dom';
import { LogOut } from 'lucide-react';
import { t } from '@/lib/i18n';
import { LogOut, Menu } from 'lucide-react';
import { t, tLocale } from '@/lib/i18n';
import LanguageSelector from '@/components/controls/LanguageSelector';
import { useLocaleContext } from '@/App';
import { useAuth } from '@/hooks/useAuth';
@ -17,42 +18,59 @@ const routeTitles: Record<string, string> = {
'/doctor': 'nav.doctor',
};
export default function Header() {
interface HeaderProps {
onToggleSidebar: () => void;
}
export default function Header({ onToggleSidebar }: HeaderProps) {
const location = useLocation();
const { logout } = useAuth();
const { locale, setAppLocale } = useLocaleContext();
const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard';
const pageTitle = t(titleKey);
const toggleLanguage = () => {
setAppLocale(locale === 'en' ? 'tr' : 'en');
};
const pageTitle = tLocale(titleKey, locale);
return (
<header className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-6">
{/* Page title */}
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
<header className="glass-header relative flex min-h-[4.5rem] flex-wrap items-center justify-between gap-2 rounded-2xl border border-[#1a3670] px-4 py-3 sm:px-5 sm:py-3.5 md:flex-nowrap md:px-8 md:py-4">
<div className="absolute inset-0 pointer-events-none opacity-70 bg-[radial-gradient(circle_at_15%_30%,rgba(41,148,255,0.22),transparent_45%),radial-gradient(circle_at_85%_75%,rgba(0,209,255,0.14),transparent_40%)]" />
{/* Right-side controls */}
<div className="flex items-center gap-4">
{/* Language switcher */}
<div className="relative flex min-w-0 items-center gap-2.5 sm:gap-3">
<button
type="button"
onClick={toggleLanguage}
className="px-3 py-1 rounded-md text-sm font-medium border border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
onClick={onToggleSidebar}
aria-label={t('navigation.open')}
className="rounded-lg border border-[#294a8f] bg-[#081637]/70 p-1.5 text-[#9ec2ff] transition hover:border-[#4f83ff] hover:text-white md:hidden"
>
{locale === 'en' ? 'EN' : 'TR'}
<Menu className="h-5 w-5" />
</button>
{/* Logout */}
<div className="min-w-0">
<h1 className="truncate text-base font-semibold tracking-wide text-white sm:text-lg">
{pageTitle}
</h1>
<p className="hidden text-[10px] uppercase tracking-[0.16em] text-[#7ea5eb] sm:block">
{tLocale('header.dashboard_tagline', locale)}
</p>
</div>
</div>
<div className="relative flex w-full items-center justify-end gap-1.5 sm:gap-2 md:w-auto md:gap-3">
<LanguageSelector
locale={locale}
onChange={setAppLocale}
ariaLabel={t('common.select_language')}
title={t('common.languages')}
align="right"
buttonClassName="flex min-w-[11rem] items-center gap-2 rounded-xl border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1 text-[#c4d8ff] shadow-[0_0_0_1px_rgba(79,131,255,0.08)] transition hover:border-[#4f83ff] hover:text-white"
/>
<button
type="button"
onClick={logout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
className="flex items-center gap-1 rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1.5 text-xs text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white sm:gap-1.5 sm:px-3 sm:text-sm"
>
<LogOut className="h-4 w-4" />
<span>{t('auth.logout')}</span>
<span className="hidden sm:inline">{t('auth.logout')}</span>
</button>
</div>
</header>

View File

@ -1,19 +1,47 @@
import { Outlet } from 'react-router-dom';
import { useState } from 'react';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
const SIDEBAR_COLLAPSED_KEY = 'zeroclaw:sidebar-collapsed';
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
});
const toggleSidebarCollapsed = () => {
setSidebarCollapsed((prev) => {
const next = !prev;
if (typeof window !== 'undefined') {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0');
}
return next;
});
};
return (
<div className="min-h-screen bg-gray-950 text-white">
{/* Fixed sidebar */}
<Sidebar />
<div className="app-shell min-h-screen text-white">
<Sidebar
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
onClose={() => setSidebarOpen(false)}
onToggleCollapse={toggleSidebarCollapsed}
/>
{/* Main area offset by sidebar width (240px / w-60) */}
<div className="ml-60 flex flex-col min-h-screen">
<Header />
<div
className={[
'flex min-h-screen flex-col transition-[margin-left] duration-300 ease-out',
sidebarCollapsed ? 'md:ml-[6.25rem]' : 'md:ml-[17.5rem]',
].join(' ')}
>
<Header onToggleSidebar={() => setSidebarOpen((open) => !open)} />
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<main className="flex-1 overflow-y-auto px-4 pb-8 pt-5 md:px-8 md:pt-8">
<Outlet />
</main>
</div>

View File

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
ChevronsLeftRightEllipsis,
LayoutDashboard,
MessageSquare,
Wrench,
@ -10,9 +12,12 @@ import {
DollarSign,
Activity,
Stethoscope,
X,
} from 'lucide-react';
import { t } from '@/lib/i18n';
const COLLAPSE_BUTTON_DELAY_MS = 1000;
const navItems = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ to: '/agent', icon: MessageSquare, labelKey: 'nav.agent' },
@ -26,40 +31,125 @@ const navItems = [
{ to: '/doctor', icon: Stethoscope, labelKey: 'nav.doctor' },
];
export default function Sidebar() {
return (
<aside className="fixed top-0 left-0 h-screen w-60 bg-gray-900 flex flex-col border-r border-gray-800">
{/* Logo / Title */}
<div className="flex items-center gap-2 px-5 py-5 border-b border-gray-800">
<div className="h-8 w-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">
ZC
</div>
<span className="text-lg font-semibold text-white tracking-wide">
ZeroClaw
</span>
</div>
interface SidebarProps {
isOpen: boolean;
isCollapsed: boolean;
onClose: () => void;
onToggleCollapse: () => void;
}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{navItems.map(({ to, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white',
].join(' ')
}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<span>{t(labelKey)}</span>
</NavLink>
))}
</nav>
</aside>
export default function Sidebar({
isOpen,
isCollapsed,
onClose,
onToggleCollapse,
}: SidebarProps) {
const [showCollapseButton, setShowCollapseButton] = useState(false);
useEffect(() => {
const id = setTimeout(() => setShowCollapseButton(true), COLLAPSE_BUTTON_DELAY_MS);
return () => clearTimeout(id);
}, []);
return (
<>
<button
type="button"
aria-label={t('navigation.close')}
onClick={onClose}
className={[
'fixed inset-0 z-30 bg-black/50 transition-opacity md:hidden',
isOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
].join(' ')}
/>
<aside
className={[
'fixed left-0 top-0 z-40 flex h-screen w-[86vw] max-w-[17.5rem] flex-col border-r border-[#1e2f5d] bg-[#050b1a]/95 backdrop-blur-xl',
'shadow-[0_0_50px_-25px_rgba(8,121,255,0.7)]',
'transform transition-[width,transform] duration-300 ease-out',
isOpen ? 'translate-x-0' : '-translate-x-full',
isCollapsed ? 'md:w-[6.25rem]' : 'md:w-[17.5rem]',
'md:translate-x-0',
].join(' ')}
>
<div className="relative flex items-center justify-between border-b border-[#1a2d5e] px-4 py-4">
<div className="flex items-center gap-3 overflow-hidden">
{!isCollapsed && (
<>
<div className="electric-brand-mark h-9 w-9 shrink-0 rounded-xl text-sm font-semibold text-white">
ZC
</div>
<span className="text-lg font-semibold tracking-[0.1em] text-white">
ZeroClaw
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{showCollapseButton && (
<button
type="button"
onClick={onToggleCollapse}
aria-label={isCollapsed ? t('navigation.expand') : t('navigation.collapse')}
className="hidden rounded-lg border border-[#2c4e97] bg-[#0a1b3f]/60 p-1.5 text-[#8bb9ff] transition hover:border-[#4f83ff] hover:text-white md:block"
>
<ChevronsLeftRightEllipsis className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onClose}
aria-label={t('navigation.close')}
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-800 hover:text-white md:hidden"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{navItems.map(({ to, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={onClose}
title={isCollapsed ? t(labelKey) : undefined}
className={({ isActive }) =>
[
'group flex items-center gap-3 overflow-hidden rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-300',
isActive
? 'border border-[#3a6de0] bg-[#0b2f80]/55 text-white shadow-[0_0_30px_-16px_rgba(72,140,255,0.95)]'
: 'border border-transparent text-[#9bb7eb] hover:border-[#294a8d] hover:bg-[#07132f] hover:text-white',
].join(' ')
}
>
<Icon className="h-5 w-5 shrink-0 transition-transform duration-300 group-hover:scale-110" />
<span
className={[
'whitespace-nowrap transition-[opacity,transform,width] duration-300',
isCollapsed ? 'w-0 -translate-x-3 opacity-0 md:invisible' : 'w-auto opacity-100',
].join(' ')}
>
{t(labelKey)}
</span>
</NavLink>
))}
</nav>
<div
className={[
'mx-3 mb-4 rounded-xl border border-[#1b3670] bg-[#071328]/80 px-3 py-3 text-xs text-[#89a9df] transition-all duration-300',
isCollapsed ? 'md:px-1.5 md:text-center' : '',
].join(' ')}
>
<p className={isCollapsed ? 'hidden md:block' : ''}>{t('sidebar.gateway_dashboard')}</p>
<p className={isCollapsed ? 'text-[10px] uppercase tracking-widest' : 'mt-1 text-[#5f84cc]'}>
{isCollapsed ? 'UI' : t('sidebar.runtime_mode')}
</p>
</div>
</aside>
</>
);
}

View File

@ -1,89 +1,598 @@
@import "tailwindcss";
/*
* ZeroClaw Dark Theme
* Dark-mode by default with gray cards and blue/green accents.
*/
@theme {
--color-bg-primary: #0a0a0f;
--color-bg-secondary: #12121a;
--color-bg-card: #1a1a2e;
--color-bg-card-hover: #22223a;
--color-bg-input: #14141f;
--color-border-default: #2a2a3e;
--color-border-subtle: #1e1e30;
--color-accent-blue: #3b82f6;
--color-accent-blue-hover: #2563eb;
--color-accent-green: #10b981;
--color-accent-green-hover: #059669;
--color-text-primary: #e2e8f0;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-status-success: #10b981;
--color-status-warning: #f59e0b;
--color-status-error: #ef4444;
--color-status-info: #3b82f6;
--color-electric-50: #eaf3ff;
--color-electric-100: #d8e8ff;
--color-electric-300: #87b8ff;
--color-electric-500: #2f8fff;
--color-electric-700: #0f57dd;
--color-electric-900: #031126;
}
/* Base styles */
html {
color-scheme: dark;
}
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
margin: 0;
min-height: 100dvh;
color: #edf4ff;
background: #020813;
font-family:
"Inter",
ui-sans-serif,
system-ui,
-apple-system,
"Sora",
"Manrope",
"Avenir Next",
"Segoe UI",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
overflow-x: hidden;
}
#root {
min-height: 100vh;
min-height: 100dvh;
}
.app-shell {
position: relative;
isolation: isolate;
background:
radial-gradient(circle at 8% 5%, rgba(47, 143, 255, 0.22), transparent 35%),
radial-gradient(circle at 92% 14%, rgba(0, 209, 255, 0.16), transparent 32%),
linear-gradient(175deg, #020816 0%, #03091b 46%, #040e24 100%);
}
.app-shell::before,
.app-shell::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
.app-shell::before {
background-image:
linear-gradient(rgba(76, 118, 194, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(76, 118, 194, 0.1) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: radial-gradient(circle at 50% 36%, black 22%, transparent 80%);
opacity: 0.35;
}
.app-shell::after {
background:
radial-gradient(circle at 16% 86%, rgba(42, 128, 255, 0.34), transparent 43%),
radial-gradient(circle at 84% 22%, rgba(0, 212, 255, 0.2), transparent 38%),
radial-gradient(circle at 52% 122%, rgba(40, 118, 255, 0.3), transparent 56%);
filter: blur(4px);
animation: appGlowDrift 28s ease-in-out infinite;
}
.glass-header {
position: relative;
backdrop-filter: blur(16px);
background: linear-gradient(160deg, rgba(6, 19, 45, 0.85), rgba(5, 14, 33, 0.9));
box-shadow:
0 18px 32px -28px rgba(68, 145, 255, 0.95),
inset 0 1px 0 rgba(140, 183, 255, 0.14);
}
.glass-header::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(105deg, transparent 10%, rgba(79, 155, 255, 0.24), transparent 70%);
transform: translateX(-70%);
animation: topGlowSweep 7s ease-in-out infinite;
pointer-events: none;
}
.hero-panel {
position: relative;
overflow: hidden;
border-radius: 1.25rem;
border: 1px solid #21438c;
padding: 1.15rem 1.2rem;
background:
radial-gradient(circle at 0% 0%, rgba(56, 143, 255, 0.24), transparent 40%),
linear-gradient(146deg, rgba(8, 26, 64, 0.95), rgba(4, 13, 34, 0.92));
box-shadow:
inset 0 1px 0 rgba(130, 174, 255, 0.16),
0 22px 50px -38px rgba(64, 145, 255, 0.94);
}
.hero-panel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(118deg, transparent, rgba(128, 184, 255, 0.12), transparent 70%);
transform: translateX(-62%);
animation: heroSweep 5.8s ease-in-out infinite;
pointer-events: none;
}
.hero-panel::before {
content: "";
position: absolute;
width: 19rem;
height: 19rem;
right: -6rem;
top: -10rem;
border-radius: 999px;
background: radial-gradient(circle at center, rgba(63, 167, 255, 0.42), transparent 70%);
filter: blur(12px);
animation: heroGlowPulse 4.8s ease-in-out infinite;
pointer-events: none;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
border: 1px solid #2d58ac;
background: rgba(6, 29, 78, 0.68);
color: #c2d9ff;
padding: 0.35rem 0.65rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.12em;
box-shadow: 0 0 22px -16px rgba(83, 153, 255, 0.95);
}
.electric-brand-mark {
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at 22% 18%, rgba(94, 200, 255, 0.35), rgba(23, 119, 255, 0.2) 52%, rgba(10, 72, 181, 0.28) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 10px 25px -12px rgba(41, 130, 255, 0.95);
}
.electric-card {
position: relative;
overflow: hidden;
border-radius: 1rem;
border: 1px solid #1a3670;
background:
linear-gradient(165deg, rgba(8, 24, 60, 0.95), rgba(4, 14, 34, 0.96));
box-shadow:
inset 0 1px 0 rgba(129, 174, 255, 0.12),
0 25px 45px -36px rgba(47, 140, 255, 0.95),
0 0 0 1px rgba(63, 141, 255, 0.2),
0 0 26px -17px rgba(76, 176, 255, 0.82);
}
.electric-card::after {
content: "";
position: absolute;
left: -20%;
right: -20%;
bottom: -65%;
height: 72%;
border-radius: 50%;
background: radial-gradient(circle, rgba(59, 148, 255, 0.25), transparent 72%);
filter: blur(16px);
opacity: 0.45;
pointer-events: none;
animation: cardGlowPulse 5.2s ease-in-out infinite;
}
.electric-icon {
display: flex;
align-items: center;
justify-content: center;
color: #9bc3ff;
background:
radial-gradient(circle at 35% 22%, rgba(123, 198, 255, 0.38), rgba(29, 92, 214, 0.32) 66%, rgba(12, 44, 102, 0.48) 100%);
border: 1px solid rgba(86, 143, 255, 0.45);
}
.metric-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
border: 1px solid #244991;
background: rgba(6, 22, 54, 0.74);
color: #91b8fb;
font-size: 0.66rem;
letter-spacing: 0.11em;
text-transform: uppercase;
padding: 0.3rem 0.55rem;
}
.metric-value {
color: #ffffff;
font-size: clamp(1.15rem, 1.8vw, 1.45rem);
font-weight: 620;
letter-spacing: 0.02em;
}
.metric-sub {
color: #89aee8;
font-size: 0.78rem;
}
.metric-pill {
border-radius: 0.85rem;
border: 1px solid #1d3c77;
background: rgba(5, 17, 44, 0.86);
padding: 0.6rem 0.72rem;
box-shadow:
0 0 0 1px rgba(62, 137, 255, 0.16),
0 16px 30px -24px rgba(47, 140, 255, 0.86),
0 0 18px -15px rgba(73, 176, 255, 0.78);
}
.metric-pill span {
display: block;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #85a9e1;
}
.metric-pill strong {
display: block;
margin-top: 0.2rem;
font-size: 0.93rem;
color: #f5f9ff;
}
.electric-progress {
background:
linear-gradient(90deg, #1f76ff 0%, #2f97ff 60%, #48cdff 100%);
box-shadow: 0 0 18px -7px rgba(62, 166, 255, 0.95);
}
.pairing-shell {
position: relative;
isolation: isolate;
min-height: 100dvh;
background:
radial-gradient(circle at 20% 5%, rgba(64, 141, 255, 0.24), transparent 35%),
radial-gradient(circle at 75% 92%, rgba(0, 193, 255, 0.13), transparent 35%),
linear-gradient(155deg, #020816 0%, #030c20 58%, #030915 100%);
overflow: hidden;
}
.pairing-shell::after {
content: "";
position: absolute;
inset: -20%;
background:
radial-gradient(circle at 10% 20%, rgba(84, 173, 255, 0.25), transparent 60%),
radial-gradient(circle at 85% 80%, rgba(0, 204, 255, 0.22), transparent 60%);
filter: blur(12px);
opacity: 0.7;
animation: pairingSpotlightSweep 18s ease-in-out infinite;
pointer-events: none;
z-index: -1;
}
.pairing-card {
position: relative;
overflow: hidden;
border: 1px solid #2956a8;
background:
linear-gradient(155deg, rgba(9, 27, 68, 0.9), rgba(4, 15, 35, 0.94));
box-shadow:
inset 0 1px 0 rgba(146, 190, 255, 0.16),
0 30px 60px -44px rgba(47, 141, 255, 0.98),
0 0 0 1px rgba(67, 150, 255, 0.2),
0 0 28px -18px rgba(76, 184, 255, 0.82);
}
.pairing-card::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, transparent 10%, rgba(102, 186, 255, 0.5), transparent 80%);
mix-blend-mode: screen;
opacity: 0.0;
transform: translateX(-65%);
animation: pairingCardSweep 7.5s ease-in-out infinite;
pointer-events: none;
}
.pairing-brand {
background-image: linear-gradient(120deg, #5bc0ff 0%, #f9e775 28%, #5bc0ff 56%, #f9e775 100%);
background-size: 260% 260%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
text-shadow:
0 0 14px rgba(96, 189, 255, 0.8),
0 0 32px rgba(46, 138, 255, 0.9),
0 0 42px rgba(252, 238, 147, 0.85);
letter-spacing: 0.18em;
animation: pairingElectricCharge 5.4s ease-in-out infinite;
}
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-xl"][class*="border"],
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-lg"][class*="border"],
:is(div, section, article)[class*="bg-gray-950"][class*="rounded-lg"][class*="border"] {
box-shadow:
0 0 0 1px rgba(67, 144, 255, 0.14),
0 22px 40px -32px rgba(45, 134, 255, 0.86),
0 0 22px -16px rgba(73, 180, 255, 0.75);
}
.electric-button {
border: 1px solid #4a89ff;
background: linear-gradient(126deg, #125bdf 0%, #1f88ff 55%, #17b4ff 100%);
box-shadow: 0 18px 30px -20px rgba(47, 141, 255, 0.9);
transition: transform 180ms ease, filter 180ms ease, box-shadow 180ms ease;
}
.electric-button:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow: 0 20px 34px -19px rgba(56, 154, 255, 0.95);
}
.electric-loader {
border: 3px solid rgba(89, 146, 255, 0.22);
border-top-color: #51abff;
box-shadow: 0 0 20px -12px rgba(66, 157, 255, 1);
animation: spin 1s linear infinite;
}
.motion-rise {
animation: riseIn 580ms ease both;
}
.motion-delay-1 {
animation-delay: 70ms;
}
.motion-delay-2 {
animation-delay: 130ms;
}
.motion-delay-3 {
animation-delay: 190ms;
}
.motion-delay-4 {
animation-delay: 250ms;
}
* {
scrollbar-width: thin;
scrollbar-color: #244787 #081126;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
background: #081126;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-default);
border-radius: 4px;
background: #244787;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: #3160b6;
}
/* Card utility */
.card {
background-color: var(--color-bg-card);
border: 1px solid var(--color-border-default);
border-radius: 0.75rem;
}
.card:hover {
background-color: var(--color-bg-card-hover);
}
/* Focus ring utility */
*:focus-visible {
outline: 2px solid var(--color-accent-blue);
outline: 2px solid #4ea4ff;
outline-offset: 2px;
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(14px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes heroSweep {
0%,
100% {
transform: translateX(-68%);
opacity: 0;
}
30% {
opacity: 0.65;
}
60% {
transform: translateX(58%);
opacity: 0;
}
}
@keyframes heroGlowPulse {
0%,
100% {
opacity: 0.38;
transform: scale(0.94);
}
50% {
opacity: 0.72;
transform: scale(1.08);
}
}
@keyframes cardGlowPulse {
0%,
100% {
opacity: 0.28;
transform: translateY(0) scale(0.96);
}
50% {
opacity: 0.55;
transform: translateY(-2%) scale(1.04);
}
}
@keyframes pairingElectricCharge {
0%,
100% {
background-position: 0% 50%;
text-shadow:
0 0 14px rgba(86, 177, 255, 0.5),
0 0 32px rgba(36, 124, 255, 0.7),
0 0 38px rgba(252, 238, 147, 0.6);
transform: translateY(0) scale(1);
}
35% {
background-position: 80% 50%;
text-shadow:
0 0 26px rgba(138, 218, 255, 1),
0 0 52px rgba(56, 176, 255, 1),
0 0 60px rgba(252, 238, 147, 1);
transform: translateY(-1px) scale(1.06);
}
60% {
background-position: 50% 50%;
text-shadow:
0 0 18px rgba(86, 177, 255, 0.7),
0 0 36px rgba(36, 124, 255, 0.8),
0 0 44px rgba(252, 238, 147, 0.7);
transform: translateY(0) scale(1.02);
}
}
@keyframes pairingSpotlightSweep {
0% {
transform: translate3d(-12%, 8%, 0) scale(1);
opacity: 0.45;
}
30% {
transform: translate3d(10%, -4%, 0) scale(1.06);
opacity: 0.7;
}
55% {
transform: translate3d(16%, 10%, 0) scale(1.1);
opacity: 0.6;
}
80% {
transform: translate3d(-8%, -6%, 0) scale(1.04);
opacity: 0.5;
}
100% {
transform: translate3d(-12%, 8%, 0) scale(1);
opacity: 0.45;
}
}
@keyframes pairingCardSweep {
0%,
100% {
transform: translateX(-70%);
opacity: 0;
}
25% {
opacity: 0.55;
}
50% {
transform: translateX(55%);
opacity: 0;
}
}
@keyframes topGlowSweep {
0%,
100% {
transform: translateX(-78%);
opacity: 0;
}
30% {
opacity: 0.55;
}
58% {
transform: translateX(58%);
opacity: 0;
}
}
@keyframes appGlowDrift {
0% {
opacity: 0.3;
transform: translate3d(-3%, 1.8%, 0) scale(1);
}
25% {
opacity: 0.5;
transform: translate3d(2.6%, -1.2%, 0) scale(1.04);
}
50% {
opacity: 0.56;
transform: translate3d(4.4%, -3.4%, 0) scale(1.09);
}
75% {
opacity: 0.44;
transform: translate3d(-1.8%, -2.1%, 0) scale(1.05);
}
100% {
opacity: 0.34;
transform: translate3d(-3.6%, 2.6%, 0) scale(1.01);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.hero-panel {
padding: 0.95rem 0.95rem;
}
.status-pill {
padding: 0.28rem 0.52rem;
font-size: 0.61rem;
letter-spacing: 0.1em;
}
.metric-value {
font-size: 1.08rem;
}
.metric-sub {
font-size: 0.74rem;
}
.electric-card {
border-radius: 0.9rem;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-panel::after,
.hero-panel::before,
.glass-header::after,
.electric-card::after,
.app-shell::after,
.pairing-shell::after,
.pairing-card::before,
.motion-rise,
.electric-loader {
animation: none !important;
}
.electric-button {
transition: none !important;
}
}

View File

@ -72,6 +72,21 @@ function unwrapField<T>(value: T | Record<string, T>, key: string): T {
return value as T;
}
function normalizeMemoryCategory(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object') {
const custom = (value as { custom?: unknown }).custom;
if (typeof custom === 'string' && custom.trim().length > 0) {
return custom;
}
}
return 'unknown';
}
// ---------------------------------------------------------------------------
// Pairing
// ---------------------------------------------------------------------------
@ -208,7 +223,11 @@ export function getMemory(
if (category) params.set('category', category);
const qs = params.toString();
return apiFetch<MemoryEntry[] | { entries: MemoryEntry[] }>(`/api/memory${qs ? `?${qs}` : ''}`).then(
(data) => unwrapField(data, 'entries'),
(data) =>
unwrapField(data, 'entries').map((entry) => ({
...entry,
category: normalizeMemoryCategory((entry as { category: unknown }).category),
})),
);
}

64
web/src/lib/i18n.test.ts Normal file
View File

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
applyLocaleToDocument,
coerceLocale,
getLanguageOption,
getLanguageOptionLabel,
getLocaleDirection,
LANGUAGE_OPTIONS,
LANGUAGE_SWITCH_ORDER,
} from './i18n';
describe('language metadata', () => {
it('keeps language options aligned with switch order', () => {
expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual(LANGUAGE_SWITCH_ORDER);
expect(new Set(LANGUAGE_OPTIONS.map((option) => option.value)).size).toBe(LANGUAGE_OPTIONS.length);
});
it('provides a flag-backed label for every locale', () => {
for (const option of LANGUAGE_OPTIONS) {
expect(getLanguageOption(option.value)).toEqual(option);
expect(getLanguageOptionLabel(option)).toBe(option.label);
expect(option.flag.length).toBeGreaterThan(0);
}
});
});
describe('coerceLocale', () => {
it('normalizes browser locale variants to supported locales', () => {
expect(coerceLocale('ar-SA')).toBe('ar');
expect(coerceLocale('he-IL')).toBe('he');
expect(coerceLocale('iw-IL')).toBe('he');
expect(coerceLocale('pt-BR')).toBe('pt');
expect(coerceLocale('no-NO')).toBe('nb');
expect(coerceLocale('zh-Hans')).toBe('zh-CN');
expect(coerceLocale(undefined)).toBe('en');
});
});
describe('locale direction', () => {
it('returns rtl only for rtl languages', () => {
expect(getLocaleDirection('ar')).toBe('rtl');
expect(getLocaleDirection('he')).toBe('rtl');
expect(getLocaleDirection('ur')).toBe('rtl');
expect(getLocaleDirection('en')).toBe('ltr');
expect(getLocaleDirection('ja')).toBe('ltr');
});
it('applies lang and dir to a document-like target', () => {
const target = {
documentElement: { lang: '', dir: '' },
body: { dir: '' },
};
applyLocaleToDocument('ar', target);
expect(target.documentElement.lang).toBe('ar');
expect(target.documentElement.dir).toBe('rtl');
expect(target.body.dir).toBe('rtl');
applyLocaleToDocument('fr', target);
expect(target.documentElement.lang).toBe('fr');
expect(target.documentElement.dir).toBe('ltr');
expect(target.body.dir).toBe('ltr');
});
});

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@ import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* Vite base '/_app/' scopes static asset URLs only; app routes stay rooted at '/' for SPA fallback. */}
<BrowserRouter basename="/">
{/* Match React Router paths to Vite's public base so in-app links resolve under /_app/. */}
<BrowserRouter basename="/_app">
<App />
</BrowserRouter>
</React.StrictMode>

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Send, Bot, User, AlertCircle } from 'lucide-react';
import type { WsMessage } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
import { t } from '@/lib/i18n';
interface ChatMessage {
id: string;
@ -35,7 +36,7 @@ export default function AgentChat() {
};
ws.onError = () => {
setError('Connection error. Attempting to reconnect...');
setError(t('agent.connection_error'));
};
ws.onMessage = (msg: WsMessage) => {
@ -67,37 +68,37 @@ export default function AgentChat() {
case 'tool_call':
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'agent',
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
},
]);
{
id: crypto.randomUUID(),
role: 'agent',
content: `[${t('agent.tool_call')}] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
},
]);
break;
case 'tool_result':
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'agent',
content: `[Tool Result] ${msg.output ?? ''}`,
timestamp: new Date(),
},
]);
{
id: crypto.randomUUID(),
role: 'agent',
content: `[${t('agent.tool_result')}] ${msg.output ?? ''}`,
timestamp: new Date(),
},
]);
break;
case 'error':
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'agent',
content: `[Error] ${msg.message ?? 'Unknown error'}`,
timestamp: new Date(),
},
]);
{
id: crypto.randomUUID(),
role: 'agent',
content: `[${t('doctor.error')}] ${msg.message ?? t('agent.unknown_error')}`,
timestamp: new Date(),
},
]);
setTyping(false);
pendingContentRef.current = '';
break;
@ -135,7 +136,7 @@ export default function AgentChat() {
setTyping(true);
pendingContentRef.current = '';
} catch {
setError('Failed to send message. Please try again.');
setError(t('agent.failed_send'));
}
setInput('');
@ -164,8 +165,8 @@ export default function AgentChat() {
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Bot className="h-12 w-12 mb-3 text-gray-600" />
<p className="text-lg font-medium">ZeroClaw Agent</p>
<p className="text-sm mt-1">Send a message to start the conversation</p>
<p className="text-lg font-medium">{t('agent.empty_title')}</p>
<p className="text-sm mt-1">{t('agent.empty_subtitle')}</p>
</div>
)}
@ -219,7 +220,7 @@ export default function AgentChat() {
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<p className="text-xs text-gray-500 mt-1">Typing...</p>
<p className="text-xs text-gray-500 mt-1">{t('agent.thinking')}</p>
</div>
</div>
)}
@ -234,10 +235,11 @@ export default function AgentChat() {
<input
ref={inputRef}
type="text"
data-testid="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={connected ? 'Type a message...' : 'Connecting...'}
placeholder={connected ? t('agent.placeholder') : t('agent.connecting')}
disabled={!connected}
className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
/>
@ -257,7 +259,7 @@ export default function AgentChat() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
</div>

View File

@ -7,6 +7,7 @@ import {
ShieldAlert,
} from 'lucide-react';
import { getConfig, putConfig } from '@/lib/api';
import { t } from '@/lib/i18n';
export default function Config() {
const [config, setConfig] = useState('');
@ -31,9 +32,9 @@ export default function Config() {
setSuccess(null);
try {
await putConfig(config);
setSuccess('Configuration saved successfully.');
setSuccess(t('config.saved'));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
setError(err instanceof Error ? err.message : t('config.error'));
} finally {
setSaving(false);
}
@ -60,7 +61,7 @@ export default function Config() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Configuration</h2>
<h2 className="text-base font-semibold text-white">{t('config.title')}</h2>
</div>
<button
onClick={handleSave}
@ -68,7 +69,7 @@ export default function Config() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('config.saving') : t('config.save')}
</button>
</div>
@ -77,11 +78,10 @@ export default function Config() {
<ShieldAlert className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-yellow-300 font-medium">
Sensitive fields are masked
{t('config.masked_title')}
</p>
<p className="text-sm text-yellow-400/70 mt-0.5">
API keys, tokens, and passwords are hidden for security. To update a
masked field, replace the entire masked value with your new value.
{t('config.masked_description')}
</p>
</div>
</div>
@ -106,10 +106,10 @@ export default function Config() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
TOML Configuration
{t('config.toml_configuration')}
</span>
<span className="text-xs text-gray-500">
{config.split('\n').length} lines
{config.split('\n').length} {t('config.lines')}
</span>
</div>
<textarea

View File

@ -7,6 +7,7 @@ import {
} from 'lucide-react';
import type { CostSummary } from '@/types/api';
import { getCost } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatUSD(value: number): string {
return `$${value.toFixed(4)}`;
@ -28,7 +29,7 @@ export default function Cost() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cost data: {error}
{t('cost.load_failed')}: {error}
</div>
</div>
);
@ -53,7 +54,7 @@ export default function Cost() {
<div className="p-2 bg-blue-600/20 rounded-lg">
<DollarSign className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Session Cost</span>
<span className="text-sm text-gray-400">{t('cost.session')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.session_cost_usd)}
@ -65,7 +66,7 @@ export default function Cost() {
<div className="p-2 bg-green-600/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Daily Cost</span>
<span className="text-sm text-gray-400">{t('cost.daily')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.daily_cost_usd)}
@ -77,7 +78,7 @@ export default function Cost() {
<div className="p-2 bg-purple-600/20 rounded-lg">
<Layers className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Monthly Cost</span>
<span className="text-sm text-gray-400">{t('cost.monthly')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.monthly_cost_usd)}
@ -89,7 +90,7 @@ export default function Cost() {
<div className="p-2 bg-orange-600/20 rounded-lg">
<Hash className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Total Requests</span>
<span className="text-sm text-gray-400">{t('cost.total_requests')}</span>
</div>
<p className="text-2xl font-bold text-white">
{cost.request_count.toLocaleString()}
@ -100,17 +101,17 @@ export default function Cost() {
{/* Token Statistics */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-base font-semibold text-white mb-4">
Token Statistics
{t('cost.token_statistics')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Total Tokens</p>
<p className="text-sm text-gray-400">{t('cost.total_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens.toLocaleString()}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Avg Tokens / Request</p>
<p className="text-sm text-gray-400">{t('cost.avg_tokens_per_request')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.request_count > 0
? Math.round(cost.total_tokens / cost.request_count).toLocaleString()
@ -118,7 +119,7 @@ export default function Cost() {
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Cost per 1K Tokens</p>
<p className="text-sm text-gray-400">{t('cost.cost_per_1k_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens > 0
? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000)
@ -132,12 +133,12 @@ export default function Cost() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-white">
Model Breakdown
{t('cost.model_breakdown')}
</h3>
</div>
{models.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No model data available.
{t('cost.no_model_data')}
</div>
) : (
<div className="overflow-x-auto">
@ -145,19 +146,19 @@ export default function Cost() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Model
{t('cost.model')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Cost
{t('cost.usd')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Tokens
{t('cost.tokens')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Requests
{t('cost.requests')}
</th>
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Share
{t('cost.share')}
</th>
</tr>
</thead>

View File

@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import type { CronJob } from '@/types/api';
import { getCronJobs, addCronJob, deleteCronJob } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatDate(iso: string | null): string {
if (!iso) return '-';
@ -45,7 +46,7 @@ export default function Cron() {
const handleAdd = async () => {
if (!formSchedule.trim() || !formCommand.trim()) {
setFormError('Schedule and command are required.');
setFormError(t('cron.schedule_required_command_required'));
return;
}
setSubmitting(true);
@ -62,7 +63,7 @@ export default function Cron() {
setFormSchedule('');
setFormCommand('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to add job');
setFormError(err instanceof Error ? err.message : t('cron.failed_add'));
} finally {
setSubmitting(false);
}
@ -73,7 +74,7 @@ export default function Cron() {
await deleteCronJob(id);
setJobs((prev) => prev.filter((j) => j.id !== id));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete job');
setError(err instanceof Error ? err.message : t('cron.failed_delete'));
} finally {
setConfirmDelete(null);
}
@ -97,7 +98,7 @@ export default function Cron() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cron jobs: {error}
{t('cron.load_failed')}: {error}
</div>
</div>
);
@ -118,7 +119,7 @@ export default function Cron() {
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Scheduled Tasks ({jobs.length})
{t('cron.scheduled_tasks')} ({jobs.length})
</h2>
</div>
<button
@ -126,7 +127,7 @@ export default function Cron() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Job
{t('cron.add')}
</button>
</div>
@ -135,7 +136,7 @@ export default function Cron() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Cron Job</h3>
<h3 className="text-lg font-semibold text-white">{t('cron.add_cron_job')}</h3>
<button
onClick={() => {
setShowForm(false);
@ -156,7 +157,7 @@ export default function Cron() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Name (optional)
{t('cron.name_optional')}
</label>
<input
type="text"
@ -168,7 +169,7 @@ export default function Cron() {
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Schedule <span className="text-red-400">*</span>
{t('cron.schedule')} <span className="text-red-400">*</span>
</label>
<input
type="text"
@ -180,7 +181,7 @@ export default function Cron() {
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Command <span className="text-red-400">*</span>
{t('cron.command')} <span className="text-red-400">*</span>
</label>
<input
type="text"
@ -200,14 +201,14 @@ export default function Cron() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Adding...' : 'Add Job'}
{submitting ? t('cron.adding') : t('cron.add')}
</button>
</div>
</div>
@ -218,7 +219,7 @@ export default function Cron() {
{jobs.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Clock className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No scheduled tasks configured.</p>
<p className="text-gray-400">{t('cron.no_tasks_configured')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
@ -229,22 +230,22 @@ export default function Cron() {
ID
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
{t('cron.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Command
{t('cron.command')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Next Run
{t('cron.next_run')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Last Status
{t('cron.last_status')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Enabled
{t('cron.enabled')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
{t('common.actions')}
</th>
</tr>
</thead>
@ -282,24 +283,24 @@ export default function Cron() {
: 'bg-gray-800 text-gray-500 border border-gray-700'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
{job.enabled ? t('cron.enabled') : t('cron.disabled')}
</span>
</td>
<td className="px-4 py-3 text-right">
{confirmDelete === job.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('cron.delete_prompt')}</span>
<button
onClick={() => handleDelete(job.id)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (

View File

@ -1,15 +1,38 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import type { ComponentType, ReactNode, SVGProps } from 'react';
import {
Cpu,
Clock,
Globe,
Database,
Activity,
ChevronDown,
Clock3,
Cpu,
Database,
DollarSign,
Globe2,
Radio,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import type { StatusResponse, CostSummary } from '@/types/api';
import { getStatus, getCost } from '@/lib/api';
import type { CostSummary, StatusResponse } from '@/types/api';
import { getCost, getStatus } from '@/lib/api';
import { t } from '@/lib/i18n';
type DashboardSectionKey = 'cost' | 'channels' | 'health';
interface DashboardSectionState {
cost: boolean;
channels: boolean;
health: boolean;
}
interface CollapsibleSectionProps {
title: string;
subtitle: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
sectionKey: DashboardSectionKey;
openState: DashboardSectionState;
onToggle: (section: DashboardSectionKey) => void;
children: ReactNode;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
@ -28,13 +51,13 @@ function healthColor(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'bg-green-500';
return 'bg-emerald-400';
case 'warn':
case 'warning':
case 'degraded':
return 'bg-yellow-500';
return 'bg-amber-400';
default:
return 'bg-red-500';
return 'bg-rose-500';
}
}
@ -42,44 +65,105 @@ function healthBorder(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'border-green-500/30';
return 'border-emerald-500/30';
case 'warn':
case 'warning':
case 'degraded':
return 'border-yellow-500/30';
return 'border-amber-400/30';
default:
return 'border-red-500/30';
return 'border-rose-500/35';
}
}
function CollapsibleSection({
title,
subtitle,
icon: Icon,
sectionKey,
openState,
onToggle,
children,
}: CollapsibleSectionProps) {
const isOpen = openState[sectionKey];
return (
<section className="electric-card motion-rise">
<button
type="button"
onClick={() => onToggle(sectionKey)}
aria-expanded={isOpen}
className="group flex w-full items-center justify-between gap-4 rounded-xl px-4 py-4 text-left md:px-5"
>
<div className="flex items-center gap-3">
<div className="electric-icon h-10 w-10 rounded-xl">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
<p className="text-xs uppercase tracking-[0.13em] text-[#7ea5eb]">{subtitle}</p>
</div>
</div>
<ChevronDown
className={[
'h-5 w-5 text-[#7ea5eb] transition-transform duration-300',
isOpen ? 'rotate-180' : 'rotate-0',
].join(' ')}
/>
</button>
<div
className={[
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
].join(' ')}
>
<div className="min-h-0 border-t border-[#18356f] px-4 pb-4 pt-4 md:px-5">{children}</div>
</div>
</section>
);
}
export default function Dashboard() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [cost, setCost] = useState<CostSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const [sectionsOpen, setSectionsOpen] = useState<DashboardSectionState>({
cost: true,
channels: true,
health: true,
});
useEffect(() => {
Promise.all([getStatus(), getCost()])
.then(([s, c]) => {
setStatus(s);
setCost(c);
.then(([statusPayload, costPayload]) => {
setStatus(statusPayload);
setCost(costPayload);
})
.catch((err) => setError(err.message));
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : t('dashboard.load_unknown_error');
setError(message);
});
}, []);
const toggleSection = (section: DashboardSectionKey) => {
setSectionsOpen((prev) => ({
...prev,
[section]: !prev[section],
}));
};
if (error) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load dashboard: {error}
</div>
<div className="electric-card p-5 text-rose-200">
<h2 className="text-lg font-semibold text-rose-100">{t('dashboard.load_failed')}</h2>
<p className="mt-2 text-sm text-rose-200/90">{error}</p>
</div>
);
}
if (!status || !cost) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="flex h-64 items-center justify-center">
<div className="electric-loader h-12 w-12 rounded-full" />
</div>
);
}
@ -87,165 +171,184 @@ export default function Dashboard() {
const maxCost = Math.max(cost.session_cost_usd, cost.daily_cost_usd, cost.monthly_cost_usd, 0.001);
return (
<div className="p-6 space-y-6">
{/* Status Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<Cpu className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Provider / Model</span>
<div className="space-y-5 md:space-y-6">
<section className="hero-panel motion-rise">
<div className="relative z-10 flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-[#8fb8ff]">{t('dashboard.hero_eyebrow')}</p>
<h1 className="mt-2 text-2xl font-semibold tracking-[0.03em] text-white md:text-3xl">
{t('dashboard.hero_title')}
</h1>
<p className="mt-2 max-w-2xl text-sm text-[#b3cbf8] md:text-base">
{t('dashboard.hero_subtitle')}
</p>
</div>
<p className="text-lg font-semibold text-white truncate">
{status.provider ?? 'Unknown'}
</p>
<p className="text-sm text-gray-400 truncate">{status.model}</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-green-600/20 rounded-lg">
<Clock className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Uptime</span>
<div className="flex flex-wrap items-center gap-2">
<span className="status-pill">
<Sparkles className="h-3.5 w-3.5" />
{t('dashboard.live_gateway')}
</span>
<span className="status-pill">
<ShieldCheck className="h-3.5 w-3.5" />
{status.paired ? t('dashboard.paired') : t('dashboard.unpaired')}
</span>
</div>
<p className="text-lg font-semibold text-white">
{formatUptime(status.uptime_seconds)}
</p>
<p className="text-sm text-gray-400">Since last restart</p>
</div>
</section>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-purple-600/20 rounded-lg">
<Globe className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Gateway Port</span>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<article className="electric-card motion-rise motion-delay-1 p-4">
<div className="metric-head">
<Cpu className="h-4 w-4" />
<span>{t('dashboard.provider_model')}</span>
</div>
<p className="text-lg font-semibold text-white">
:{status.gateway_port}
</p>
<p className="text-sm text-gray-400">Locale: {status.locale}</p>
</div>
<p className="metric-value mt-3">{status.provider ?? t('dashboard.unknown_provider')}</p>
<p className="metric-sub mt-1 truncate">{status.model}</p>
</article>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-orange-600/20 rounded-lg">
<Database className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Memory Backend</span>
<article className="electric-card motion-rise motion-delay-2 p-4">
<div className="metric-head">
<Clock3 className="h-4 w-4" />
<span>{t('dashboard.uptime')}</span>
</div>
<p className="text-lg font-semibold text-white capitalize">
{status.memory_backend}
</p>
<p className="text-sm text-gray-400">
Paired: {status.paired ? 'Yes' : 'No'}
</p>
</div>
</div>
<p className="metric-value mt-3">{formatUptime(status.uptime_seconds)}</p>
<p className="metric-sub mt-1">{t('dashboard.since_last_restart')}</p>
</article>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Cost Widget */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<DollarSign className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Cost Overview</h2>
<article className="electric-card motion-rise motion-delay-3 p-4">
<div className="metric-head">
<Globe2 className="h-4 w-4" />
<span>{t('dashboard.gateway_port')}</span>
</div>
<p className="metric-value mt-3">:{status.gateway_port}</p>
<p className="metric-sub mt-1">{status.locale}</p>
</article>
<article className="electric-card motion-rise motion-delay-4 p-4">
<div className="metric-head">
<Database className="h-4 w-4" />
<span>{t('dashboard.memory_backend')}</span>
</div>
<p className="metric-value mt-3 capitalize">{status.memory_backend}</p>
<p className="metric-sub mt-1">{status.paired ? t('dashboard.pairing_active') : t('dashboard.no_paired_devices')}</p>
</article>
</section>
<div className="space-y-4">
<CollapsibleSection
title={t('dashboard.cost_pulse')}
subtitle={t('dashboard.cost_subtitle')}
icon={DollarSign}
sectionKey="cost"
openState={sectionsOpen}
onToggle={toggleSection}
>
<div className="space-y-4">
{[
{ label: 'Session', value: cost.session_cost_usd, color: 'bg-blue-500' },
{ label: 'Daily', value: cost.daily_cost_usd, color: 'bg-green-500' },
{ label: 'Monthly', value: cost.monthly_cost_usd, color: 'bg-purple-500' },
].map(({ label, value, color }) => (
{ label: t('dashboard.session'), value: cost.session_cost_usd },
{ label: t('dashboard.daily'), value: cost.daily_cost_usd },
{ label: t('dashboard.monthly'), value: cost.monthly_cost_usd },
].map(({ label, value }) => (
<div key={label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">{label}</span>
<span className="text-white font-medium">{formatUSD(value)}</span>
<div className="mb-1.5 flex items-center justify-between text-sm">
<span className="text-[#9bb8ec]">{label}</span>
<span className="font-semibold text-white">{formatUSD(value)}</span>
</div>
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden">
<div className="h-2.5 overflow-hidden rounded-full bg-[#061230]">
<div
className={`h-full rounded-full ${color}`}
style={{ width: `${Math.max((value / maxCost) * 100, 2)}%` }}
className="electric-progress h-full rounded-full"
style={{ width: `${Math.max((value / maxCost) * 100, 3)}%` }}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-gray-800 flex justify-between text-sm">
<span className="text-gray-400">Total Tokens</span>
<span className="text-white">{cost.total_tokens.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-400">Requests</span>
<span className="text-white">{cost.request_count.toLocaleString()}</span>
</div>
</div>
{/* Active Channels */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Radio className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Active Channels</h2>
<div className="grid grid-cols-2 gap-3 pt-2">
<div className="metric-pill">
<span>{t('cost.total_tokens')}</span>
<strong>{cost.total_tokens.toLocaleString()}</strong>
</div>
<div className="metric-pill">
<span>{t('cost.requests')}</span>
<strong>{cost.request_count.toLocaleString()}</strong>
</div>
</div>
</div>
<div className="space-y-2">
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-gray-500">No channels configured</p>
) : (
Object.entries(status.channels).map(([name, active]) => (
</CollapsibleSection>
<CollapsibleSection
title={t('dashboard.channel_activity')}
subtitle={t('dashboard.channel_subtitle')}
icon={Radio}
sectionKey="channels"
openState={sectionsOpen}
onToggle={toggleSection}
>
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-[#8aa8df]">{t('dashboard.no_channels')}</p>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{Object.entries(status.channels).map(([name, active]) => (
<div
key={name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-800/50"
className="rounded-xl border border-[#1d3770] bg-[#05112c]/90 px-3 py-2.5"
>
<span className="text-sm text-white capitalize">{name}</span>
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${
active ? 'bg-green-500' : 'bg-gray-500'
}`}
/>
<span className="text-xs text-gray-400">
{active ? 'Active' : 'Inactive'}
<div className="flex items-center justify-between">
<span className="text-sm capitalize text-white">{name}</span>
<span className="flex items-center gap-2 text-xs text-[#8baee7]">
<span
className={[
'inline-block h-2.5 w-2.5 rounded-full',
active ? 'bg-emerald-400 shadow-[0_0_12px_0_rgba(52,211,153,0.8)]' : 'bg-slate-500',
].join(' ')}
/>
{active ? t('dashboard.active') : t('dashboard.inactive')}
</span>
</div>
</div>
))
)}
</div>
</div>
))}
</div>
)}
</CollapsibleSection>
{/* Health Grid */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Component Health</h2>
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-gray-500 col-span-2">No components reporting</p>
) : (
Object.entries(status.health.components).map(([name, comp]) => (
<CollapsibleSection
title={t('dashboard.component_health')}
subtitle={t('dashboard.component_subtitle')}
icon={Activity}
sectionKey="health"
openState={sectionsOpen}
onToggle={toggleSection}
>
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-[#8aa8df]">{t('dashboard.no_component_health')}</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{Object.entries(status.health.components).map(([name, component]) => (
<div
key={name}
className={`rounded-lg p-3 border ${healthBorder(comp.status)} bg-gray-800/50`}
className={[
'rounded-xl border bg-[#05112c]/80 px-3 py-3',
healthBorder(component.status),
].join(' ')}
>
<div className="flex items-center gap-2 mb-1">
<span className={`inline-block h-2 w-2 rounded-full ${healthColor(comp.status)}`} />
<span className="text-sm font-medium text-white capitalize truncate">
{name}
</span>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold capitalize text-white">{name}</p>
<span className={['inline-block h-2.5 w-2.5 rounded-full', healthColor(component.status)].join(' ')} />
</div>
<p className="text-xs text-gray-400 capitalize">{comp.status}</p>
{comp.restart_count > 0 && (
<p className="text-xs text-yellow-400 mt-1">
Restarts: {comp.restart_count}
<p className="mt-1 text-xs uppercase tracking-[0.12em] text-[#87a9e5]">
{component.status}
</p>
{component.restart_count > 0 && (
<p className="mt-2 text-xs text-amber-300">
{t('dashboard.restarts')}: {component.restart_count}
</p>
)}
</div>
))
)}
</div>
</div>
))}
</div>
)}
</CollapsibleSection>
</div>
</div>
);

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { DiagResult } from '@/types/api';
import { runDoctor } from '@/lib/api';
import { t } from '@/lib/i18n';
function severityIcon(severity: DiagResult['severity']) {
switch (severity) {
@ -56,7 +57,7 @@ export default function Doctor() {
const data = await runDoctor();
setResults(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to run diagnostics');
setError(err instanceof Error ? err.message : t('doctor.error'));
} finally {
setLoading(false);
}
@ -82,7 +83,7 @@ export default function Doctor() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Diagnostics</h2>
<h2 className="text-base font-semibold text-white">{t('doctor.title')}</h2>
</div>
<button
onClick={handleRun}
@ -92,12 +93,12 @@ export default function Doctor() {
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Running...
{t('doctor.running_short')}
</>
) : (
<>
<Play className="h-4 w-4" />
Run Diagnostics
{t('doctor.run')}
</>
)}
</button>
@ -114,9 +115,9 @@ export default function Doctor() {
{loading && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-10 w-10 text-blue-500 animate-spin mb-4" />
<p className="text-gray-400">Running diagnostics...</p>
<p className="text-gray-400">{t('doctor.running')}</p>
<p className="text-sm text-gray-500 mt-1">
This may take a few seconds.
{t('doctor.running_hint')}
</p>
</div>
)}
@ -128,44 +129,44 @@ export default function Doctor() {
<div className="flex items-center gap-4 bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-sm text-white font-medium">
{okCount} <span className="text-gray-400 font-normal">ok</span>
<span className="text-sm text-white font-medium">
{okCount} <span className="text-gray-400 font-normal">{t('doctor.ok').toLowerCase()}</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-400" />
<span className="text-sm text-white font-medium">
{warnCount}{' '}
<span className="text-gray-400 font-normal">
warning{warnCount !== 1 ? 's' : ''}
<span className="text-sm text-white font-medium">
{warnCount}{' '}
<span className="text-gray-400 font-normal">
{t('doctor.warnings').toLowerCase()}
</span>
</span>
</span>
</div>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-400" />
<span className="text-sm text-white font-medium">
{errorCount}{' '}
<span className="text-gray-400 font-normal">
error{errorCount !== 1 ? 's' : ''}
<span className="text-sm text-white font-medium">
{errorCount}{' '}
<span className="text-gray-400 font-normal">
{t('doctor.error').toLowerCase()}
</span>
</span>
</span>
</div>
</div>
{/* Overall indicator */}
<div className="ml-auto">
{errorCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-900/40 text-red-400 border border-red-700/50">
Issues Found
{t('doctor.issues_found')}
</span>
) : warnCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-yellow-900/40 text-yellow-400 border border-yellow-700/50">
Warnings
{t('doctor.warnings')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-green-900/40 text-green-400 border border-green-700/50">
All Clear
{t('doctor.all_clear')}
</span>
)}
</div>
@ -204,13 +205,13 @@ export default function Doctor() {
{/* Empty state */}
{!results && !loading && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Stethoscope className="h-12 w-12 text-gray-600 mb-4" />
<p className="text-lg font-medium">System Diagnostics</p>
<p className="text-sm mt-1">
Click "Run Diagnostics" to check your ZeroClaw installation.
</p>
</div>
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Stethoscope className="h-12 w-12 text-gray-600 mb-4" />
<p className="text-lg font-medium">{t('doctor.title')}</p>
<p className="text-sm mt-1">
{t('doctor.instructions')}
</p>
</div>
)}
</div>
);

View File

@ -2,27 +2,28 @@ import { useState, useEffect } from 'react';
import { Puzzle, Check, Zap, Clock } from 'lucide-react';
import type { Integration } from '@/types/api';
import { getIntegrations } from '@/lib/api';
import { t } from '@/lib/i18n';
function statusBadge(status: Integration['status']) {
switch (status) {
case 'Active':
return {
icon: Check,
label: 'Active',
classes: 'bg-green-900/40 text-green-400 border-green-700/50',
};
return {
icon: Check,
label: t('integrations.active'),
classes: 'bg-green-900/40 text-green-400 border-green-700/50',
};
case 'Available':
return {
icon: Zap,
label: 'Available',
classes: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
};
return {
icon: Zap,
label: t('integrations.available'),
classes: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
};
case 'ComingSoon':
return {
icon: Clock,
label: 'Coming Soon',
classes: 'bg-gray-800 text-gray-400 border-gray-700',
};
return {
icon: Clock,
label: t('integrations.coming_soon'),
classes: 'bg-gray-800 text-gray-400 border-gray-700',
};
}
}
@ -61,7 +62,7 @@ export default function Integrations() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load integrations: {error}
{t('integrations.load_failed')}: {error}
</div>
</div>
);
@ -78,12 +79,12 @@ export default function Integrations() {
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Integrations ({integrations.length})
</h2>
</div>
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
{t('integrations.title')} ({integrations.length})
</h2>
</div>
{/* Category Filter Tabs */}
<div className="flex flex-wrap gap-2">
@ -106,7 +107,7 @@ export default function Integrations() {
{Object.keys(grouped).length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Puzzle className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No integrations found.</p>
<p className="text-gray-400">{t('integrations.empty')}</p>
</div>
) : (
Object.entries(grouped)

View File

@ -8,6 +8,7 @@ import {
} from 'lucide-react';
import type { SSEEvent } from '@/types/api';
import { SSEClient } from '@/lib/sse';
import { t } from '@/lib/i18n';
function formatTimestamp(ts?: string): string {
if (!ts) return new Date().toLocaleTimeString();
@ -138,7 +139,7 @@ export default function Logs() {
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Live Logs</h2>
<h2 className="text-base font-semibold text-white">{t('logs.title')}</h2>
<div className="flex items-center gap-2 ml-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
@ -146,11 +147,11 @@ export default function Logs() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
<span className="text-xs text-gray-500 ml-2">
{filteredEntries.length} events
{filteredEntries.length} {t('logs.events')}
</span>
</div>
@ -166,11 +167,11 @@ export default function Logs() {
>
{paused ? (
<>
<Play className="h-3.5 w-3.5" /> Resume
<Play className="h-3.5 w-3.5" /> {t('logs.resume')}
</>
) : (
<>
<Pause className="h-3.5 w-3.5" /> Pause
<Pause className="h-3.5 w-3.5" /> {t('logs.pause')}
</>
)}
</button>
@ -182,7 +183,7 @@ export default function Logs() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
<ArrowDown className="h-3.5 w-3.5" />
Jump to bottom
{t('logs.jump_to_bottom')}
</button>
)}
</div>
@ -192,7 +193,7 @@ export default function Logs() {
{allTypes.length > 0 && (
<div className="flex items-center gap-2 px-6 py-2 border-b border-gray-800 bg-gray-900/80 overflow-x-auto">
<Filter className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span className="text-xs text-gray-500 flex-shrink-0">Filter:</span>
<span className="text-xs text-gray-500 flex-shrink-0">{t('logs.filter_label')}</span>
{allTypes.map((type) => (
<label
key={type}
@ -212,7 +213,7 @@ export default function Logs() {
onClick={() => setTypeFilters(new Set())}
className="text-xs text-blue-400 hover:text-blue-300 flex-shrink-0 ml-1"
>
Clear
{t('logs.clear')}
</button>
)}
</div>
@ -229,8 +230,8 @@ export default function Logs() {
<Activity className="h-10 w-10 text-gray-600 mb-3" />
<p className="text-sm">
{paused
? 'Log streaming is paused.'
: 'Waiting for events...'}
? t('logs.paused_stream')
: t('logs.waiting_for_events')}
</p>
</div>
) : (

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { MemoryEntry } from '@/types/api';
import { getMemory, storeMemory, deleteMemory } from '@/lib/api';
import { t } from '@/lib/i18n';
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
@ -60,7 +61,7 @@ export default function Memory() {
const handleAdd = async () => {
if (!formKey.trim() || !formContent.trim()) {
setFormError('Key and content are required.');
setFormError(t('memory.key_content_required'));
return;
}
setSubmitting(true);
@ -77,7 +78,7 @@ export default function Memory() {
setFormContent('');
setFormCategory('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to store memory');
setFormError(err instanceof Error ? err.message : t('memory.failed_store'));
} finally {
setSubmitting(false);
}
@ -88,7 +89,7 @@ export default function Memory() {
await deleteMemory(key);
setEntries((prev) => prev.filter((e) => e.key !== key));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete memory');
setError(err instanceof Error ? err.message : t('memory.failed_delete'));
} finally {
setConfirmDelete(null);
}
@ -98,7 +99,7 @@ export default function Memory() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load memory: {error}
{t('memory.load_failed')}: {error}
</div>
</div>
);
@ -111,7 +112,7 @@ export default function Memory() {
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Memory ({entries.length})
{t('nav.memory')} ({entries.length})
</h2>
</div>
<button
@ -119,7 +120,7 @@ export default function Memory() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Memory
{t('memory.add_memory')}
</button>
</div>
@ -132,7 +133,7 @@ export default function Memory() {
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search memory entries..."
placeholder={t('memory.search_entries')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -143,7 +144,7 @@ export default function Memory() {
onChange={(e) => setCategoryFilter(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-8 py-2.5 text-sm text-white appearance-none focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
>
<option value="">All Categories</option>
<option value="">{t('memory.all_categories')}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
@ -155,7 +156,7 @@ export default function Memory() {
onClick={handleSearch}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Search
{t('memory.search_button')}
</button>
</div>
@ -171,7 +172,7 @@ export default function Memory() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Memory</h3>
<h3 className="text-lg font-semibold text-white">{t('memory.add_memory')}</h3>
<button
onClick={() => {
setShowForm(false);
@ -192,37 +193,37 @@ export default function Memory() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Key <span className="text-red-400">*</span>
{t('memory.key')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value)}
placeholder="e.g. user_preferences"
placeholder={t('memory.key_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Content <span className="text-red-400">*</span>
{t('memory.content')} <span className="text-red-400">*</span>
</label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
placeholder="Memory content..."
placeholder={t('memory.content_placeholder')}
rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Category (optional)
{t('memory.category_optional')}
</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g. preferences, context, facts"
placeholder={t('memory.category_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -236,14 +237,14 @@ export default function Memory() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save'}
{submitting ? t('memory.saving') : t('common.save')}
</button>
</div>
</div>
@ -258,7 +259,7 @@ export default function Memory() {
) : entries.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Brain className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No memory entries found.</p>
<p className="text-gray-400">{t('memory.empty')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
@ -266,19 +267,19 @@ export default function Memory() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Key
{t('memory.key')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Content
{t('memory.content')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
{t('memory.category')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Timestamp
{t('memory.timestamp')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
{t('common.actions')}
</th>
</tr>
</thead>
@ -307,18 +308,18 @@ export default function Memory() {
<td className="px-4 py-3 text-right">
{confirmDelete === entry.key ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('memory.delete_prompt')}</span>
<button
onClick={() => handleDelete(entry.key)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { ToolSpec, CliTool } from '@/types/api';
import { getTools, getCliTools } from '@/lib/api';
import { t } from '@/lib/i18n';
export default function Tools() {
const [tools, setTools] = useState<ToolSpec[]>([]);
@ -44,7 +45,7 @@ export default function Tools() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load tools: {error}
{t('tools.load_failed')}: {error}
</div>
</div>
);
@ -67,7 +68,7 @@ export default function Tools() {
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
placeholder={t('tools.search')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@ -77,12 +78,12 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Wrench className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Agent Tools ({filtered.length})
{t('tools.agent_tools')} ({filtered.length})
</h2>
</div>
{filtered.length === 0 ? (
<p className="text-sm text-gray-500">No tools match your search.</p>
<p className="text-sm text-gray-500">{t('tools.no_search_results')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((tool) => {
@ -119,7 +120,7 @@ export default function Tools() {
{isExpanded && tool.parameters && (
<div className="border-t border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wider">
Parameter Schema
{t('tools.parameter_schema')}
</p>
<pre className="text-xs text-gray-300 bg-gray-950 rounded-lg p-3 overflow-x-auto max-h-64 overflow-y-auto">
{JSON.stringify(tool.parameters, null, 2)}
@ -139,7 +140,7 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Terminal className="h-5 w-5 text-green-400" />
<h2 className="text-base font-semibold text-white">
CLI Tools ({filteredCli.length})
{t('tools.cli_tools')} ({filteredCli.length})
</h2>
</div>
@ -148,16 +149,16 @@ export default function Tools() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
{t('tools.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Path
{t('tools.path')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Version
{t('tools.version')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
{t('integrations.category')}
</th>
</tr>
</thead>

View File

@ -3,27 +3,40 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
base: "/_app/",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "dist",
},
server: {
proxy: {
"/api": {
target: "http://localhost:5555",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:5555",
ws: true,
export default defineConfig(() => {
const apiTarget = process.env.VITE_API_TARGET ?? "http://127.0.0.1:42617";
const wsTarget = process.env.VITE_WS_TARGET ?? apiTarget.replace(/^http/, "ws");
return {
base: "/_app/",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
},
build: {
outDir: "dist",
},
server: {
proxy: {
"/health": {
target: apiTarget,
changeOrigin: true,
},
"/pair": {
target: apiTarget,
changeOrigin: true,
},
"/api": {
target: apiTarget,
changeOrigin: true,
},
"/ws": {
target: wsTarget,
ws: true,
},
},
},
};
});

15
web/vitest.config.ts Normal file
View File

@ -0,0 +1,15 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
exclude: ['e2e/**'],
},
});