feat(web): port electric dashboard UI from source repo

This commit is contained in:
argenis de la rosa 2026-03-07 17:37:46 -05:00
parent a6102f8dd6
commit b248d40abc
54 changed files with 6816 additions and 806 deletions

35
run_dashboard.sh Executable file
View File

@ -0,0 +1,35 @@
#!/bin/bash
# Quick setup script to run ZeroClaw web dashboard
echo "🦀 ZeroClaw Web Dashboard Setup"
echo "================================"
# Check if web assets are built
if [ ! -d "web/dist" ]; then
echo "📦 Building web assets first..."
cd web
npm run build
cd ..
echo "✅ Web assets built!"
fi
# Build the project
echo "🔨 Building ZeroClaw binary with embedded web dashboard..."
cargo build --release
# Check if build was successful
if [ -f "target/release/zeroclaw" ]; then
echo "✅ Build successful! Web dashboard is embedded in the binary."
echo ""
echo "🚀 Starting ZeroClaw Gateway..."
echo "📱 Dashboard URL: http://127.0.0.1:3000/"
echo "🔧 API Endpoint: http://127.0.0.1:3000/api/"
echo "⏹️ Press Ctrl+C to stop the gateway"
echo ""
# Start the gateway
./target/release/zeroclaw gateway --open-dashboard
else
echo "❌ Build failed! Please check the error messages above."
exit 1
fi

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
web/dist/index.html vendored
View File

@ -3,10 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws: wss: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
/>
<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-CW_YEDAa.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-Bud_uSBD.css">
</head>
<body>
<div id="root"></div>

View File

@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test';
test.describe('Dashboard mobile smoke', () => {
test.use({
viewport: { width: 390, height: 844 },
});
test('renders mock dashboard and supports mobile navigation and collapsible cards', async ({ page }) => {
await page.route('**/health', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
paired: true,
require_pairing: false,
}),
});
});
await page.route('**/api/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
provider: 'openai',
model: 'gpt-5.2',
temperature: 0.4,
uptime_seconds: 68420,
gateway_port: 42617,
locale: 'en-US',
memory_backend: 'sqlite',
paired: true,
channels: {
telegram: true,
discord: false,
whatsapp: true,
github: true,
},
health: {
uptime_seconds: 68420,
updated_at: '2026-03-02T19:34:29.678544+00:00',
pid: 4242,
components: {
gateway: {
status: 'ok',
updated_at: '2026-03-02T19:34:29.678544+00:00',
last_ok: '2026-03-02T19:34:29.678544+00:00',
last_error: null,
restart_count: 0,
},
},
},
}),
});
});
await page.route('**/api/cost', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
cost: {
session_cost_usd: 0.0842,
daily_cost_usd: 1.3026,
monthly_cost_usd: 14.9875,
total_tokens: 182342,
request_count: 426,
by_model: {
'gpt-5.2': {
model: 'gpt-5.2',
cost_usd: 11.4635,
total_tokens: 141332,
request_count: 292,
},
},
},
}),
});
});
await page.goto('/');
await expect(page.getByText('Electric Runtime Dashboard')).toBeVisible();
await page.getByRole('button', { name: 'Open navigation' }).click();
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
const closeButtons = page.getByRole('button', { name: 'Close navigation' });
await closeButtons.first().click();
const costPulseButton = page.getByRole('button', { name: /Cost Pulse/i });
await expect(costPulseButton).toHaveAttribute('aria-expanded', 'true');
await costPulseButton.click();
await expect(costPulseButton).toHaveAttribute('aria-expanded', 'false');
});
});

View File

@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws: wss: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
/>
<meta name="color-scheme" content="dark" />
<title>ZeroClaw</title>
</head>

8
web/netlify.toml Normal file
View File

@ -0,0 +1,8 @@
[build]
command = "npm run build"
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

1652
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,22 +7,36 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:mobile-smoke": "node ./scripts/mobile-smoke-runner.mjs"
},
"dependencies": {
"@codemirror/language": "^6.12.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.15",
"@uiw/react-codemirror": "^4.25.5",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": "^7.1.1",
"smol-toml": "^1.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.0",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.1.0",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^3.2.4"
}
}

31
web/package.nix Normal file
View File

@ -0,0 +1,31 @@
{ buildNpmPackage, lib }:
buildNpmPackage {
pname = "zeroclaw-web";
version = "0.1.0";
src =
let
fs = lib.fileset;
in
fs.toSource {
root = ./.;
fileset = fs.unions [
./src
./index.html
./package.json
./package-lock.json
./tsconfig.json
./tsconfig.app.json
./tsconfig.node.json
./vite.config.ts
];
};
npmDepsHash = "sha256-H3extDaq4DgNYTUcw57gqwVWc3aPCWjIJEVYRMzdFdM=";
installPhase = ''
runHook preInstall
cp -r dist $out
runHook postInstall
'';
}

15
web/playwright.config.mjs Normal file
View File

@ -0,0 +1,15 @@
export default {
testDir: './e2e',
timeout: 30_000,
retries: 0,
use: {
baseURL: 'http://127.0.0.1:4173',
headless: true,
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
port: 4173,
reuseExistingServer: true,
timeout: 120_000,
},
};

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -0,0 +1,56 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const isWin = process.platform === 'win32';
function localBin(name) {
const suffix = isWin ? '.cmd' : '';
return path.join(rootDir, 'node_modules', '.bin', `${name}${suffix}`);
}
function run(command, args) {
const result = spawnSync(command, args, {
cwd: rootDir,
stdio: 'inherit',
env: process.env,
});
if (result.error) {
throw result.error;
}
return result.status ?? 1;
}
function hasPlaywright() {
try {
require.resolve('@playwright/test', { paths: [rootDir] });
return true;
} catch {
return false;
}
}
const vitestArgs = [
'run',
'src/pages/Dashboard.test.tsx',
'src/components/layout/Sidebar.test.tsx',
];
if (hasPlaywright()) {
console.log('[mobile-smoke] Playwright detected. Running browser + fallback smoke tests.');
const playwrightStatus = run(localBin('playwright'), ['test', 'e2e/dashboard.mobile.spec.ts']);
if (playwrightStatus !== 0) {
process.exit(playwrightStatus);
}
process.exit(run(localBin('vitest'), vitestArgs));
}
console.log('[mobile-smoke] @playwright/test not vendored. Running Vitest mobile smoke fallback.');
process.exit(run(localBin('vitest'), vitestArgs));

View File

@ -7,22 +7,25 @@ import Tools from './pages/Tools';
import Cron from './pages/Cron';
import Integrations from './pages/Integrations';
import Memory from './pages/Memory';
import Devices from './pages/Devices';
import Config from './pages/Config';
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 { coerceLocale, setLocale, type Locale } from './lib/i18n';
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);
@ -47,11 +50,11 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
};
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">
<div className="pairing-card w-full max-w-md rounded-2xl p-8">
<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]">Enter the one-time pairing code from your terminal</p>
</div>
<form onSubmit={handleSubmit}>
<input
@ -59,17 +62,17 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
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"
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"
className="electric-button w-full rounded-xl py-3 font-medium text-white disabled:opacity-50"
>
{loading ? 'Pairing...' : 'Pair'}
</button>
@ -81,11 +84,28 @@ 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>(() => {
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);
});
useEffect(() => {
setLocale(locale);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
}
}, [locale]);
const setAppLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
setLocale(newLocale as Locale);
};
// Listen for 401 events to force logout
@ -99,8 +119,11 @@ 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]">Connecting...</p>
</div>
</div>
);
}
@ -119,6 +142,7 @@ function AppContent() {
<Route path="/cron" element={<Cron />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/memory" element={<Memory />} />
<Route path="/devices" element={<Devices />} />
<Route path="/config" element={<Config />} />
<Route path="/cost" element={<Cost />} />
<Route path="/logs" element={<Logs />} />

View File

@ -0,0 +1,121 @@
import { useState, useMemo } from 'react';
import { Search } from 'lucide-react';
import { CONFIG_SECTIONS } from './configSections';
import ConfigSection from './ConfigSection';
import type { FieldDef } from './types';
const CATEGORY_ORDER = [
{ key: 'all', label: 'All' },
{ key: 'general', label: 'General' },
{ key: 'security', label: 'Security' },
{ key: 'channels', label: 'Channels' },
{ key: 'runtime', label: 'Runtime' },
{ key: 'tools', label: 'Tools' },
{ key: 'memory', label: 'Memory' },
{ key: 'network', label: 'Network' },
{ key: 'advanced', label: 'Advanced' },
] as const;
interface Props {
getFieldValue: (sectionPath: string, fieldKey: string) => unknown;
setFieldValue: (sectionPath: string, fieldKey: string, value: unknown) => void;
isFieldMasked: (sectionPath: string, fieldKey: string) => boolean;
}
export default function ConfigFormEditor({
getFieldValue,
setFieldValue,
isFieldMasked,
}: Props) {
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('all');
const isSearching = search.trim().length > 0;
const filteredSections = useMemo(() => {
if (isSearching) {
const q = search.toLowerCase();
return CONFIG_SECTIONS.map((section) => {
const titleMatch = section.title.toLowerCase().includes(q);
const descMatch = section.description?.toLowerCase().includes(q);
if (titleMatch || descMatch) {
return { section, fields: undefined };
}
const matchingFields = section.fields.filter(
(f: FieldDef) =>
f.label.toLowerCase().includes(q) ||
f.key.toLowerCase().includes(q) ||
f.description?.toLowerCase().includes(q),
);
if (matchingFields.length > 0) {
return { section, fields: matchingFields };
}
return null;
}).filter(Boolean) as { section: (typeof CONFIG_SECTIONS)[0]; fields: FieldDef[] | undefined }[];
}
// Category filter
const sections = activeCategory === 'all'
? CONFIG_SECTIONS
: CONFIG_SECTIONS.filter((s) => s.category === activeCategory);
return sections.map((s) => ({ section: s, fields: undefined }));
}, [search, isSearching, activeCategory]);
return (
<div className="space-y-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search config fields..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Category pills — hidden during search */}
{!isSearching && (
<div className="flex flex-wrap gap-2">
{CATEGORY_ORDER.map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveCategory(key)}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
activeCategory === key
? 'bg-blue-600 text-white'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:bg-gray-800 hover:text-gray-200'
}`}
>
{label}
</button>
))}
</div>
)}
{/* Sections */}
{filteredSections.length === 0 ? (
<div className="text-center py-12 text-gray-500 text-sm">
No matching config fields found.
</div>
) : (
filteredSections.map(({ section, fields }) => (
<ConfigSection
key={section.path || '_root'}
section={fields ? { ...section, defaultCollapsed: false } : section}
getFieldValue={getFieldValue}
setFieldValue={setFieldValue}
isFieldMasked={isFieldMasked}
visibleFields={fields}
/>
))
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { StreamLanguage } from '@codemirror/language';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import CodeMirror from '@uiw/react-codemirror';
interface Props {
rawToml: string;
onChange: (raw: string) => void;
disabled?: boolean;
}
const tomlLanguage = StreamLanguage.define(toml);
export default function ConfigRawEditor({ rawToml, onChange, disabled }: Props) {
return (
<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
</span>
<span className="text-xs text-gray-500">
{rawToml.split('\n').length} lines
</span>
</div>
<CodeMirror
value={rawToml}
onChange={onChange}
theme={oneDark}
readOnly={Boolean(disabled)}
editable={!disabled}
height="500px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
highlightActiveLineGutter: false,
highlightActiveLine: false,
}}
extensions={[tomlLanguage, EditorView.lineWrapping]}
className="text-sm [&_.cm-scroller]:font-mono [&_.cm-scroller]:leading-6 [&_.cm-content]:py-4 [&_.cm-content]:px-0 [&_.cm-gutters]:border-r [&_.cm-gutters]:border-gray-800 [&_.cm-gutters]:bg-gray-950 [&_.cm-editor]:bg-gray-950 [&_.cm-editor]:focus:outline-none [&_.cm-focused]:ring-2 [&_.cm-focused]:ring-blue-500/70 [&_.cm-focused]:ring-inset"
aria-label="Raw TOML configuration editor with syntax highlighting"
/>
</div>
);
}

View File

@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react';
import type { SectionDef, FieldDef } from './types';
import TextField from './fields/TextField';
import NumberField from './fields/NumberField';
import ToggleField from './fields/ToggleField';
import SelectField from './fields/SelectField';
import TagListField from './fields/TagListField';
interface Props {
section: SectionDef;
getFieldValue: (sectionPath: string, fieldKey: string) => unknown;
setFieldValue: (sectionPath: string, fieldKey: string, value: unknown) => void;
isFieldMasked: (sectionPath: string, fieldKey: string) => boolean;
visibleFields?: FieldDef[];
}
function renderField(
field: FieldDef,
value: unknown,
onChange: (v: unknown) => void,
isMasked: boolean,
) {
const props = { field, value, onChange, isMasked };
switch (field.type) {
case 'text':
case 'password':
return <TextField {...props} />;
case 'number':
return <NumberField {...props} />;
case 'toggle':
return <ToggleField {...props} />;
case 'select':
return <SelectField {...props} />;
case 'tag-list':
return <TagListField {...props} />;
default:
return <TextField {...props} />;
}
}
export default function ConfigSection({
section,
getFieldValue,
setFieldValue,
isFieldMasked,
visibleFields,
}: Props) {
const [collapsed, setCollapsed] = useState(section.defaultCollapsed ?? false);
const sectionPanelId = useMemo(
() =>
`config-section-${(section.path || 'root').replace(/[^a-zA-Z0-9_-]/g, '-')}`,
[section.path],
);
const Icon = section.icon;
const fields = visibleFields ?? section.fields;
useEffect(() => {
setCollapsed(section.defaultCollapsed ?? false);
}, [section.path, section.defaultCollapsed]);
return (
<div className="bg-gray-900 rounded-xl border border-gray-800">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
aria-expanded={!collapsed}
aria-controls={sectionPanelId}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition-colors rounded-t-xl"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-gray-500 flex-shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500 flex-shrink-0" />
)}
<Icon className="h-4 w-4 text-blue-400 flex-shrink-0" />
<span className="text-sm font-medium text-white">{section.title}</span>
{section.description && (
<span className="text-xs text-gray-500 hidden sm:inline">
{section.description}
</span>
)}
<span className="ml-auto text-xs text-gray-600">
{fields.length} {fields.length === 1 ? 'field' : 'fields'}
</span>
</button>
{!collapsed && (
<div
id={sectionPanelId}
className="border-t border-gray-800 px-4 py-4 grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4"
>
{fields.map((field) => {
const value = getFieldValue(section.path, field.key);
const masked = isFieldMasked(section.path, field.key);
const spanFull = field.type === 'tag-list';
return (
<div key={field.key} className={`flex flex-col${spanFull ? ' sm:col-span-2' : ''}`}>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-1.5">
<span>{field.label}</span>
{field.sensitive && (
<span className="text-[10px] text-yellow-400 bg-yellow-900/30 border border-yellow-800/50 px-1.5 py-0.5 rounded">
sensitive
</span>
)}
{masked && (
<span className="text-[10px] text-blue-400 bg-blue-900/30 border border-blue-800/50 px-1.5 py-0.5 rounded">
masked
</span>
)}
</label>
{field.description && field.type !== 'text' && field.type !== 'password' && field.type !== 'number' && (
<p className="text-xs text-gray-500 mb-1.5">{field.description}</p>
)}
<div className="mt-auto">
{renderField(
field,
value,
(v) => setFieldValue(section.path, field.key, v),
masked,
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
import type { FieldProps } from '../types';
export default function NumberField({ field, value, onChange }: FieldProps) {
const numValue = value === undefined || value === null || value === '' ? '' : Number(value);
return (
<input
type="number"
value={numValue}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
onChange(undefined);
return;
}
const n = Number(raw);
if (!isNaN(n)) {
onChange(n);
}
}}
onBlur={(e) => {
if (field.step !== undefined && field.step < 1) {
return;
}
const raw = e.target.value;
if (raw === '') {
return;
}
const n = Number(raw);
if (!isNaN(n)) {
onChange(Math.floor(n));
}
}}
min={field.min}
max={field.max}
step={field.step ?? 1}
placeholder={field.description ?? ''}
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"
/>
);
}

View File

@ -0,0 +1,20 @@
import type { FieldProps } from '../types';
export default function SelectField({ field, value, onChange }: FieldProps) {
const strValue = (value as string) ?? '';
return (
<select
value={strValue}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}

View File

@ -0,0 +1,60 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import type { FieldProps } from '../types';
export default function TagListField({ field, value, onChange }: FieldProps) {
const [input, setInput] = useState('');
const tags: string[] = Array.isArray(value) ? value : [];
const addTag = (tag: string) => {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed]);
}
setInput('');
};
const removeTag = (index: number) => {
onChange(tags.filter((_, i) => i !== index));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(input);
} else if (e.key === 'Backspace' && input === '' && tags.length > 0) {
removeTag(tags.length - 1);
}
};
return (
<div>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map((tag, i) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-700 text-gray-200 rounded-full px-2.5 py-0.5 text-xs"
>
{tag}
<button
type="button"
onClick={() => removeTag(i)}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { if (input.trim()) addTag(input); }}
placeholder={field.tagPlaceholder ?? 'Type and press Enter to add'}
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>
);
}

View File

@ -0,0 +1,39 @@
import { useState } from 'react';
import { Eye, EyeOff, Lock } from 'lucide-react';
import type { FieldProps } from '../types';
export default function TextField({ field, value, onChange, isMasked }: FieldProps) {
const [showPassword, setShowPassword] = useState(false);
const isPassword = field.type === 'password';
const strValue = isMasked ? '' : ((value as string) ?? '');
return (
<div className="relative">
<input
type={isPassword && !showPassword ? 'password' : 'text'}
value={strValue}
onChange={(e) => onChange(e.target.value)}
placeholder={isMasked ? 'Configured (masked)' : field.description ?? ''}
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 pr-16"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{isMasked && (
<Lock className="h-3.5 w-3.5 text-yellow-500" />
)}
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1 text-gray-400 hover:text-gray-200 transition-colors"
>
{showPassword ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import type { FieldProps } from '../types';
export default function ToggleField({ field, value, onChange }: FieldProps) {
const isOn = Boolean(value);
return (
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={isOn}
aria-label={field.label}
onClick={() => onChange(!isOn)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isOn ? 'bg-blue-600' : 'bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isOn ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className="text-sm text-gray-400">{isOn ? 'Enabled' : 'Disabled'}</span>
</div>
);
}

View File

@ -0,0 +1,40 @@
import type { LucideIcon } from 'lucide-react';
export type FieldType =
| 'text'
| 'password'
| 'number'
| 'toggle'
| 'select'
| 'tag-list';
export interface FieldDef {
key: string;
label: string;
type: FieldType;
description?: string;
sensitive?: boolean;
defaultValue?: unknown;
options?: { value: string; label: string }[];
min?: number;
max?: number;
step?: number;
tagPlaceholder?: string;
}
export interface SectionDef {
path: string;
title: string;
description?: string;
icon: LucideIcon;
fields: FieldDef[];
defaultCollapsed?: boolean;
category?: string;
}
export interface FieldProps {
field: FieldDef;
value: unknown;
onChange: (value: unknown) => void;
isMasked: boolean;
}

View File

@ -0,0 +1,307 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { parse, stringify } from 'smol-toml';
import { getConfig, putConfig } from '@/lib/api';
const MASKED = '***MASKED***';
type ParsedConfig = Record<string, unknown>;
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
/** Recursively scan for MASKED strings and collect their dotted paths. */
function scanMasked(obj: unknown, prefix: string, out: Set<string>) {
if (obj === null || obj === undefined) return;
if (typeof obj === 'string' && obj === MASKED) {
out.add(prefix);
return;
}
if (Array.isArray(obj)) {
obj.forEach((item, i) => {
scanMasked(item, `${prefix}.${i}`, out);
});
return;
}
if (typeof obj === 'object') {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
scanMasked(v, prefix ? `${prefix}.${k}` : k, out);
}
}
}
/** Navigate into an object by dotted path segments, returning the value. */
function getNestedValue(obj: unknown, segments: string[]): unknown {
let current: unknown = obj;
for (const seg of segments) {
if (current === null || current === undefined || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[seg];
}
return current;
}
/** Set a value in an object by dotted path segments, creating intermediates. */
function setNestedValue(obj: Record<string, unknown>, segments: string[], value: unknown) {
if (segments.length === 0) return;
let current: Record<string, unknown> = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg: string = segments[i]!;
if (current[seg] === undefined || current[seg] === null || typeof current[seg] !== 'object') {
current[seg] = {};
}
current = current[seg] as Record<string, unknown>;
}
const lastSeg: string = segments[segments.length - 1]!;
if (value === undefined || value === '') {
delete current[lastSeg];
} else {
current[lastSeg] = value;
}
}
export type EditorMode = 'form' | 'raw';
export interface ConfigFormState {
loading: boolean;
saving: boolean;
error: string | null;
success: string | null;
mode: EditorMode;
rawToml: string;
parsed: ParsedConfig;
maskedPaths: Set<string>;
dirtyPaths: Set<string>;
setMode: (mode: EditorMode) => boolean;
getFieldValue: (sectionPath: string, fieldKey: string) => unknown;
setFieldValue: (sectionPath: string, fieldKey: string, value: unknown) => void;
isFieldMasked: (sectionPath: string, fieldKey: string) => boolean;
isFieldDirty: (sectionPath: string, fieldKey: string) => boolean;
setRawToml: (raw: string) => void;
save: () => Promise<void>;
reload: () => Promise<void>;
clearMessages: () => void;
}
export function useConfigForm(): ConfigFormState {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [mode, setModeState] = useState<EditorMode>('form');
const [rawToml, setRawTomlState] = useState('');
const [parsed, setParsed] = useState<ParsedConfig>({});
const maskedPathsRef = useRef<Set<string>>(new Set());
const dirtyPathsRef = useRef<Set<string>>(new Set());
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [, forceRender] = useState(0);
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getConfig();
const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
setRawTomlState(raw);
try {
const obj = parse(raw) as ParsedConfig;
setParsed(obj);
const masked = new Set<string>();
scanMasked(obj, '', masked);
maskedPathsRef.current = masked;
} catch {
// If TOML parse fails, start in raw mode
setParsed({});
maskedPathsRef.current = new Set();
setModeState('raw');
}
dirtyPathsRef.current = new Set();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load configuration');
} finally {
setLoading(false);
}
}, []);
// Load once on mount.
const hasLoaded = useRef(false);
useEffect(() => {
if (!hasLoaded.current) {
hasLoaded.current = true;
void loadConfig();
}
}, [loadConfig]);
useEffect(() => {
return () => {
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
};
}, []);
const fieldPath = (sectionPath: string, fieldKey: string) =>
sectionPath ? `${sectionPath}.${fieldKey}` : fieldKey;
const fieldSegments = (sectionPath: string, fieldKey: string) => {
const full = fieldPath(sectionPath, fieldKey);
return full.split('.').filter(Boolean);
};
const getFieldValue = useCallback(
(sectionPath: string, fieldKey: string): unknown => {
const segments = fieldSegments(sectionPath, fieldKey);
return getNestedValue(parsed, segments);
},
[parsed],
);
const setFieldValue = useCallback(
(sectionPath: string, fieldKey: string, value: unknown) => {
const fp = fieldPath(sectionPath, fieldKey);
const segments = fieldSegments(sectionPath, fieldKey);
setParsed((prev) => {
const next = deepClone(prev);
setNestedValue(next, segments, value);
return next;
});
dirtyPathsRef.current.add(fp);
forceRender((n) => n + 1);
},
[],
);
const isFieldMasked = useCallback(
(sectionPath: string, fieldKey: string): boolean => {
const fp = fieldPath(sectionPath, fieldKey);
return maskedPathsRef.current.has(fp) && !dirtyPathsRef.current.has(fp);
},
[],
);
const isFieldDirty = useCallback(
(sectionPath: string, fieldKey: string): boolean => {
const fp = fieldPath(sectionPath, fieldKey);
return dirtyPathsRef.current.has(fp);
},
[],
);
const syncFormToRaw = useCallback((): string => {
try {
const toml = stringify(parsed);
return toml;
} catch {
return rawToml;
}
}, [parsed, rawToml]);
const syncRawToForm = useCallback(
(raw: string): boolean => {
try {
const obj = parse(raw) as ParsedConfig;
setParsed(obj);
// Re-scan masked paths from fresh parse, preserving dirty overrides
const masked = new Set<string>();
scanMasked(obj, '', masked);
maskedPathsRef.current = masked;
return true;
} catch {
return false;
}
},
[],
);
const setMode = useCallback(
(newMode: EditorMode): boolean => {
if (newMode === mode) return true;
if (newMode === 'raw') {
// form → raw: serialize parsed to TOML
const toml = syncFormToRaw();
setRawTomlState(toml);
setModeState('raw');
return true;
} else {
// raw → form: parse TOML
if (syncRawToForm(rawToml)) {
setModeState('form');
return true;
} else {
setError('Invalid TOML syntax. Fix errors before switching to Form view.');
return false;
}
}
},
[mode, syncFormToRaw, syncRawToForm, rawToml],
);
const setRawToml = useCallback((raw: string) => {
setRawTomlState(raw);
}, []);
const save = useCallback(async () => {
setSaving(true);
setError(null);
setSuccess(null);
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
try {
let toml: string;
if (mode === 'form') {
toml = syncFormToRaw();
} else {
toml = rawToml;
}
await putConfig(toml);
setSuccess('Configuration saved successfully.');
// Auto-dismiss success after 4 seconds
successTimeoutRef.current = setTimeout(() => setSuccess(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
setSaving(false);
}
}, [mode, syncFormToRaw, rawToml]);
const reload = useCallback(async () => {
await loadConfig();
}, [loadConfig]);
const clearMessages = useCallback(() => {
setError(null);
setSuccess(null);
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = null;
}
}, []);
return {
loading,
saving,
error,
success,
mode,
rawToml,
parsed,
maskedPaths: maskedPathsRef.current,
dirtyPaths: dirtyPathsRef.current,
setMode,
getFieldValue,
setFieldValue,
isFieldMasked,
isFieldDirty,
setRawToml,
save,
reload,
clearMessages,
};
}

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ChatChannelsGuide from './ChatChannelsGuide';
import { CHAT_CHANNEL_SUPPORT } from '@/lib/chatChannels';
describe('ChatChannelsGuide', () => {
it('renders the supported channel matrix and key notes', () => {
render(<ChatChannelsGuide />);
expect(screen.getByText('Supported Chat Channels')).toBeInTheDocument();
expect(
screen.getByText(`${CHAT_CHANNEL_SUPPORT.length} channels listed`),
).toBeInTheDocument();
expect(screen.getByText('BlueBubbles')).toBeInTheDocument();
expect(screen.getByText('WhatsApp')).toBeInTheDocument();
expect(screen.getByText('Zalo Personal')).toBeInTheDocument();
expect(screen.getByText('Channel Notes')).toBeInTheDocument();
});
it('supports collapsing and expanding the section', async () => {
const user = userEvent.setup();
render(<ChatChannelsGuide />);
const toggle = screen.getByRole('button', {
name: /Supported Chat Channels/i,
});
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('BlueBubbles')).toBeInTheDocument();
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('BlueBubbles')).not.toBeInTheDocument();
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('BlueBubbles')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,104 @@
import { useState } from 'react';
import { ChevronDown, MessageCircleMore, Sparkles } from 'lucide-react';
import {
CHAT_CHANNEL_NOTES,
CHAT_CHANNEL_SUPPORT,
type ChatChannelSupportLevel,
} from '@/lib/chatChannels';
const SUPPORT_LEVEL_CLASSES: Record<ChatChannelSupportLevel, string> = {
'Built-in': 'border-[#2f63c8] bg-[#0a265f]/70 text-[#acd0ff]',
Plugin: 'border-[#2f5ea0] bg-[#071a41]/80 text-[#8eb8f4]',
Legacy: 'border-[#5f6080] bg-[#141731]/80 text-[#c2c5e8]',
};
export default function ChatChannelsGuide() {
const [isOpen, setIsOpen] = useState(true);
return (
<section className="electric-card motion-rise">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
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">
<MessageCircleMore className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-semibold text-white">
Supported Chat Channels
</h2>
<p className="text-xs uppercase tracking-[0.13em] text-[#7ea5eb]">
{CHAT_CHANNEL_SUPPORT.length} channels listed
</p>
</div>
</div>
<ChevronDown
className={[
'h-5 w-5 text-[#7ea5eb] transition-transform duration-300',
isOpen ? 'rotate-180' : 'rotate-0',
].join(' ')}
/>
</button>
{isOpen && (
<div className="border-t border-[#18356f] px-4 pb-5 pt-4 md:px-5">
<div className="rounded-xl border border-[#1e3a78] bg-[#07142f]/85 p-3 md:p-4">
<p className="text-sm leading-relaxed text-[#c8dcff]">
ZeroClaw can talk to you on the chat apps you already use through
Gateway. Text is supported across all channels; media and reactions
vary by channel.
</p>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{CHAT_CHANNEL_SUPPORT.map((channel) => (
<article
key={channel.id}
className="rounded-xl border border-[#1f3d76] bg-[#060f25]/85 p-3 shadow-[0_0_22px_-15px_rgba(80,176,255,0.9)]"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{channel.name}</h3>
<span
className={[
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium',
SUPPORT_LEVEL_CLASSES[channel.supportLevel],
].join(' ')}
>
{channel.supportLevel}
</span>
</div>
<p className="mt-2 text-xs leading-relaxed text-[#97baee]">
{channel.summary}
</p>
{channel.details && (
<p className="mt-2 text-[11px] leading-relaxed text-[#7ca6de]">
{channel.details}
</p>
)}
{channel.recommended && (
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-[#cfe3ff]">
<Sparkles className="h-3 w-3" />
Recommended
</p>
)}
</article>
))}
</div>
<div className="mt-4 rounded-xl border border-[#1b3770] bg-[#061129]/85 p-3 md:p-4">
<h3 className="text-sm font-semibold text-white">Channel Notes</h3>
<ul className="mt-2 space-y-1.5 text-xs leading-relaxed text-[#9bbce8]">
{CHAT_CHANNEL_NOTES.map((note) => (
<li key={note}> {note}</li>
))}
</ul>
</div>
</div>
)}
</section>
);
}

View File

@ -1,6 +1,6 @@
import { useLocation } from 'react-router-dom';
import { LogOut } from 'lucide-react';
import { t } from '@/lib/i18n';
import { LogOut, Menu } from 'lucide-react';
import { t, LANGUAGE_BUTTON_LABELS, LANGUAGE_SWITCH_ORDER } from '@/lib/i18n';
import { useLocaleContext } from '@/App';
import { useAuth } from '@/hooks/useAuth';
@ -11,13 +11,20 @@ const routeTitles: Record<string, string> = {
'/cron': 'nav.cron',
'/integrations': 'nav.integrations',
'/memory': 'nav.memory',
'/devices': 'nav.devices',
'/config': 'nav.config',
'/cost': 'nav.cost',
'/logs': 'nav.logs',
'/doctor': 'nav.doctor',
};
export default function Header() {
const languageSummary = 'English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά';
interface HeaderProps {
onToggleSidebar: () => void;
}
export default function Header({ onToggleSidebar }: HeaderProps) {
const location = useLocation();
const { logout } = useAuth();
const { locale, setAppLocale } = useLocaleContext();
@ -26,33 +33,53 @@ export default function Header() {
const pageTitle = t(titleKey);
const toggleLanguage = () => {
setAppLocale(locale === 'en' ? 'tr' : 'en');
const currentIndex = LANGUAGE_SWITCH_ORDER.indexOf(locale);
const nextLocale =
LANGUAGE_SWITCH_ORDER[(currentIndex + 1) % LANGUAGE_SWITCH_ORDER.length] ?? 'en';
setAppLocale(nextLocale);
};
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={onToggleSidebar}
aria-label="Open navigation"
className="rounded-lg border border-[#294a8f] bg-[#081637]/70 p-1.5 text-[#9ec2ff] transition hover:border-[#4f83ff] hover:text-white md:hidden"
>
<Menu className="h-5 w-5" />
</button>
<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">
ZeroClaw dashboard
</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">
<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"
title={`🌐 Languages: ${languageSummary}`}
className="rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1 text-xs font-medium text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white sm:px-3 sm:text-sm"
>
{locale === 'en' ? 'EN' : 'TR'}
{LANGUAGE_BUTTON_LABELS[locale] ?? 'EN'}
</button>
{/* Logout */}
<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

@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import Sidebar from './Sidebar';
function renderSidebar({
isOpen = false,
isCollapsed = false,
onClose = vi.fn(),
onToggleCollapse = vi.fn(),
}: {
isOpen?: boolean;
isCollapsed?: boolean;
onClose?: () => void;
onToggleCollapse?: () => void;
} = {}) {
const view = render(
<MemoryRouter>
<Sidebar
isOpen={isOpen}
isCollapsed={isCollapsed}
onClose={onClose}
onToggleCollapse={onToggleCollapse}
/>
</MemoryRouter>
);
return { ...view, onClose, onToggleCollapse };
}
describe('Sidebar', () => {
it('toggles open/close state and invokes close handlers for mobile controls', async () => {
const user = userEvent.setup();
const closed = renderSidebar({ isOpen: false });
const closedButtons = closed.getAllByRole('button', {
name: /Close navigation/i,
});
expect(closedButtons.length).toBeGreaterThan(0);
const closedOverlay = closedButtons[0];
if (!closedOverlay) {
throw new Error('Expected sidebar overlay button');
}
expect(closedOverlay).toHaveClass('pointer-events-none');
closed.unmount();
const opened = renderSidebar({ isOpen: true });
const openedCloseButtons = opened.getAllByRole('button', {
name: /Close navigation/i,
});
expect(openedCloseButtons.length).toBeGreaterThanOrEqual(2);
const openedOverlay = openedCloseButtons[0];
const mobileCloseButton = openedCloseButtons[1];
if (!openedOverlay || !mobileCloseButton) {
throw new Error('Expected sidebar overlay and close buttons');
}
expect(openedOverlay).toHaveClass('opacity-100');
await user.click(openedOverlay);
await user.click(mobileCloseButton);
expect(opened.onClose).toHaveBeenCalledTimes(2);
});
it('supports collapsed mode controls and closes on navigation click', () => {
vi.useFakeTimers();
try {
const view = renderSidebar({ isOpen: true, isCollapsed: true });
act(() => {
vi.advanceTimersByTime(1_000);
});
const collapseToggle = screen.getByRole('button', {
name: /Expand navigation/i,
});
fireEvent.click(collapseToggle);
expect(view.onToggleCollapse).toHaveBeenCalledTimes(1);
const dashboardLink = screen.getByRole('link', { name: 'Dashboard' });
expect(dashboardLink).toHaveAttribute('title', 'Dashboard');
fireEvent.click(dashboardLink);
expect(view.onClose).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});

View File

@ -1,18 +1,24 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
ChevronsLeftRightEllipsis,
LayoutDashboard,
MessageSquare,
Wrench,
Clock,
Puzzle,
Brain,
Smartphone,
Settings,
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' },
@ -20,46 +26,134 @@ const navItems = [
{ to: '/cron', icon: Clock, labelKey: 'nav.cron' },
{ to: '/integrations', icon: Puzzle, labelKey: 'nav.integrations' },
{ to: '/memory', icon: Brain, labelKey: 'nav.memory' },
{ to: '/devices', icon: Smartphone, labelKey: 'nav.devices' },
{ to: '/config', icon: Settings, labelKey: 'nav.config' },
{ to: '/cost', icon: DollarSign, labelKey: 'nav.cost' },
{ to: '/logs', icon: Activity, labelKey: 'nav.logs' },
{ 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="Close navigation"
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 && (
<>
<img
src="/_app/logo.png"
alt="ZeroClaw"
className="h-9 w-9 shrink-0 rounded-xl object-contain"
/>
<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 ? 'Expand navigation' : 'Collapse navigation'}
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="Close navigation"
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' : ''}>Gateway + Dashboard</p>
<p className={isCollapsed ? 'text-[10px] uppercase tracking-widest' : 'mt-1 text-[#5f84cc]'}>
{isCollapsed ? 'UI' : 'Runtime Mode'}
</p>
</div>
</aside>
</>
);
}

View File

@ -12,6 +12,7 @@ import {
setToken as writeToken,
clearToken as removeToken,
isAuthenticated as checkAuth,
TOKEN_STORAGE_KEY,
} from '../lib/auth';
import { pair as apiPair, getPublicHealth } from '../lib/api';
@ -69,10 +70,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
};
}, []);
// Keep state in sync if localStorage is changed in another tab
// Keep state in sync if token storage is changed from another browser context.
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === 'zeroclaw_token') {
if (e.key === TOKEN_STORAGE_KEY) {
const t = readToken();
setTokenState(t);
setAuthenticated(t !== null && t.length > 0);

View File

@ -1,89 +1,600 @@
@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.22), rgba(23, 119, 255, 0.14) 52%, rgba(10, 72, 181, 0.18) 100%),
url("/logo/background.png") center / cover no-repeat;
background-blend-mode: screen;
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

@ -3,8 +3,10 @@ import type {
ToolSpec,
CronJob,
Integration,
IntegrationSettingsPayload,
DiagResult,
MemoryEntry,
PairedDevice,
CostSummary,
CliTool,
HealthSnapshot,
@ -184,6 +186,23 @@ export function getIntegrations(): Promise<Integration[]> {
);
}
export function getIntegrationSettings(): Promise<IntegrationSettingsPayload> {
return apiFetch<IntegrationSettingsPayload>('/api/integrations/settings');
}
export function putIntegrationCredentials(
integrationId: string,
body: { revision?: string; fields: Record<string, string> },
): Promise<{ status: string; revision: string; unchanged?: boolean }> {
return apiFetch<{ status: string; revision: string; unchanged?: boolean }>(
`/api/integrations/${encodeURIComponent(integrationId)}/credentials`,
{
method: 'PUT',
body: JSON.stringify(body),
},
);
}
// ---------------------------------------------------------------------------
// Doctor / Diagnostics
// ---------------------------------------------------------------------------
@ -229,6 +248,22 @@ export function deleteMemory(key: string): Promise<void> {
});
}
// ---------------------------------------------------------------------------
// Paired Devices
// ---------------------------------------------------------------------------
export function getPairedDevices(): Promise<PairedDevice[]> {
return apiFetch<PairedDevice[] | { devices: PairedDevice[] }>('/api/pairing/devices').then(
(data) => unwrapField(data, 'devices'),
);
}
export function revokePairedDevice(id: string): Promise<void> {
return apiFetch<void>(`/api/pairing/devices/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
// ---------------------------------------------------------------------------
// Cost
// ---------------------------------------------------------------------------

View File

@ -1,36 +1,84 @@
const TOKEN_KEY = 'zeroclaw_token';
export const TOKEN_STORAGE_KEY = 'zeroclaw_token';
let inMemoryToken: string | null = null;
function readStorage(key: string): string | null {
try {
return sessionStorage.getItem(key);
} catch {
return null;
}
}
function writeStorage(key: string, value: string): void {
try {
sessionStorage.setItem(key, value);
} catch {
// sessionStorage may be unavailable in some browser privacy modes
}
}
function removeStorage(key: string): void {
try {
sessionStorage.removeItem(key);
} catch {
// Ignore
}
}
function clearLegacyLocalStorageToken(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Ignore
}
}
/**
* Retrieve the stored authentication token.
*/
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
if (inMemoryToken && inMemoryToken.length > 0) {
return inMemoryToken;
}
const sessionToken = readStorage(TOKEN_STORAGE_KEY);
if (sessionToken && sessionToken.length > 0) {
inMemoryToken = sessionToken;
return sessionToken;
}
// One-time migration from older localStorage-backed sessions.
try {
const legacy = localStorage.getItem(TOKEN_STORAGE_KEY);
if (legacy && legacy.length > 0) {
inMemoryToken = legacy;
writeStorage(TOKEN_STORAGE_KEY, legacy);
localStorage.removeItem(TOKEN_STORAGE_KEY);
return legacy;
}
} catch {
// Ignore
}
return null;
}
/**
* Store an authentication token.
*/
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
// localStorage may be unavailable (e.g. in some private browsing modes)
}
inMemoryToken = token;
writeStorage(TOKEN_STORAGE_KEY, token);
clearLegacyLocalStorageToken(TOKEN_STORAGE_KEY);
}
/**
* Remove the stored authentication token.
*/
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
// Ignore
}
inMemoryToken = null;
removeStorage(TOKEN_STORAGE_KEY);
clearLegacyLocalStorageToken(TOKEN_STORAGE_KEY);
}
/**

171
web/src/lib/chatChannels.ts Normal file
View File

@ -0,0 +1,171 @@
export type ChatChannelSupportLevel = 'Built-in' | 'Plugin' | 'Legacy';
export interface ChatChannelSupport {
id: string;
name: string;
supportLevel: ChatChannelSupportLevel;
summary: string;
details?: string;
recommended?: boolean;
}
export const CHAT_CHANNEL_SUPPORT: ChatChannelSupport[] = [
{
id: 'bluebubbles',
name: 'BlueBubbles',
supportLevel: 'Built-in',
recommended: true,
summary: 'Recommended for iMessage with BlueBubbles macOS server REST API.',
details:
'Supports edit, unsend, effects, reactions, and group management. Edit is currently broken on macOS 26 Tahoe.',
},
{
id: 'discord',
name: 'Discord',
supportLevel: 'Built-in',
summary: 'Discord Bot API + Gateway for servers, channels, and direct messages.',
},
{
id: 'feishu',
name: 'Feishu',
supportLevel: 'Plugin',
summary: 'Feishu/Lark bot integration over WebSocket.',
details: 'Plugin track, installed separately.',
},
{
id: 'google-chat',
name: 'Google Chat',
supportLevel: 'Built-in',
summary: 'Google Chat app integration via HTTP webhook.',
},
{
id: 'imessage-legacy',
name: 'iMessage (legacy)',
supportLevel: 'Legacy',
summary: 'Legacy macOS integration via imsg CLI.',
details: 'Deprecated path for new setups; BlueBubbles is recommended.',
},
{
id: 'irc',
name: 'IRC',
supportLevel: 'Built-in',
summary: 'Classic IRC channels and DMs with pairing and allowlist controls.',
},
{
id: 'line',
name: 'LINE',
supportLevel: 'Plugin',
summary: 'LINE Messaging API bot integration.',
details: 'Plugin track, installed separately.',
},
{
id: 'matrix',
name: 'Matrix',
supportLevel: 'Plugin',
summary: 'Matrix protocol integration for rooms and direct messaging.',
details: 'Plugin track, installed separately.',
},
{
id: 'mattermost',
name: 'Mattermost',
supportLevel: 'Plugin',
summary: 'Bot API + WebSocket for channels, groups, and DMs.',
details: 'Plugin track, installed separately.',
},
{
id: 'microsoft-teams',
name: 'Microsoft Teams',
supportLevel: 'Plugin',
summary: 'Enterprise support track for Teams environments.',
details: 'Plugin track, installed separately.',
},
{
id: 'nextcloud-talk',
name: 'Nextcloud Talk',
supportLevel: 'Plugin',
summary: 'Self-hosted chat via Nextcloud Talk integration.',
details: 'Plugin track, installed separately.',
},
{
id: 'nostr',
name: 'Nostr',
supportLevel: 'Plugin',
summary: 'Decentralized encrypted DMs via NIP-04 and modern NIP flows.',
details: 'Plugin track, installed separately.',
},
{
id: 'signal',
name: 'Signal',
supportLevel: 'Built-in',
summary: 'Privacy-focused messaging through signal-cli.',
},
{
id: 'synology-chat',
name: 'Synology Chat',
supportLevel: 'Plugin',
summary: 'Synology NAS Chat via outgoing and incoming webhooks.',
details: 'Plugin track, installed separately.',
},
{
id: 'slack',
name: 'Slack',
supportLevel: 'Built-in',
summary: 'Slack workspace apps powered by Bolt SDK.',
},
{
id: 'telegram',
name: 'Telegram',
supportLevel: 'Built-in',
summary: 'Bot API integration via grammY with strong group support.',
},
{
id: 'tlon',
name: 'Tlon',
supportLevel: 'Plugin',
summary: 'Urbit-based messenger integration path.',
details: 'Plugin track, installed separately.',
},
{
id: 'twitch',
name: 'Twitch',
supportLevel: 'Plugin',
summary: 'Twitch chat support over IRC connection.',
details: 'Plugin track, installed separately.',
},
{
id: 'webchat',
name: 'WebChat',
supportLevel: 'Built-in',
summary: 'Gateway WebChat UI over WebSocket for browser-based sessions.',
},
{
id: 'whatsapp',
name: 'WhatsApp',
supportLevel: 'Built-in',
summary: 'Baileys-backed integration with QR pairing flow.',
},
{
id: 'zalo',
name: 'Zalo',
supportLevel: 'Plugin',
summary: "Zalo Bot API for Vietnam's popular messenger ecosystem.",
details: 'Plugin track, installed separately.',
},
{
id: 'zalo-personal',
name: 'Zalo Personal',
supportLevel: 'Plugin',
summary: 'Personal account integration with QR login.',
details: 'Plugin track, installed separately.',
},
];
export const CHAT_CHANNEL_NOTES: string[] = [
'Channels can run simultaneously; configure multiple and ZeroClaw routes per chat.',
'Fastest initial setup is usually Telegram with a simple bot token.',
'WhatsApp requires local state on disk for persistent sessions.',
'Group behavior varies by channel. See docs/channels-reference.md for policy details.',
'DM pairing and allowlists are enforced for safety. See docs/security/README.md.',
'Troubleshooting lives in docs/troubleshooting.md under channel guidance.',
'Model providers are documented separately in docs/providers-reference.md.',
];

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

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { coerceLocale, LANGUAGE_SWITCH_ORDER } from './i18n';
describe('i18n locale support', () => {
it('normalizes locale hints for the supported language set', () => {
expect(coerceLocale('en-US')).toBe('en');
expect(coerceLocale('zh')).toBe('zh-CN');
expect(coerceLocale('zh-HK')).toBe('zh-CN');
expect(coerceLocale('ja-JP')).toBe('ja');
expect(coerceLocale('ru-RU')).toBe('ru');
expect(coerceLocale('fr-CA')).toBe('fr');
expect(coerceLocale('vi-VN')).toBe('vi');
expect(coerceLocale('el-GR')).toBe('el');
});
it('falls back to English for unknown locales', () => {
expect(coerceLocale('es-ES')).toBe('en');
expect(coerceLocale('pt-BR')).toBe('en');
expect(coerceLocale(undefined)).toBe('en');
});
it('uses the expected language switch order', () => {
expect(LANGUAGE_SWITCH_ORDER).toEqual([
'en',
'zh-CN',
'ja',
'ru',
'fr',
'vi',
'el',
]);
});
});

View File

@ -5,7 +5,39 @@ import { getStatus } from './api';
// Translation dictionaries
// ---------------------------------------------------------------------------
export type Locale = 'en' | 'tr';
export type Locale = 'en' | 'tr' | 'zh-CN' | 'ja' | 'ru' | 'fr' | 'vi' | 'el';
export const LANGUAGE_SWITCH_ORDER: ReadonlyArray<Locale> = [
'en',
'zh-CN',
'ja',
'ru',
'fr',
'vi',
'el',
];
export const LANGUAGE_BUTTON_LABELS: Record<Locale, string> = {
en: 'EN',
tr: 'TR',
'zh-CN': '简体',
ja: '日本語',
ru: 'РУ',
fr: 'FR',
vi: 'VI',
el: 'ΕΛ',
};
const KNOWN_LOCALES: ReadonlyArray<Locale> = [
'en',
'tr',
'zh-CN',
'ja',
'ru',
'fr',
'vi',
'el',
];
const translations: Record<Locale, Record<string, string>> = {
en: {
@ -16,6 +48,7 @@ const translations: Record<Locale, Record<string, string>> = {
'nav.cron': 'Scheduled Jobs',
'nav.integrations': 'Integrations',
'nav.memory': 'Memory',
'nav.devices': 'Devices',
'nav.config': 'Configuration',
'nav.cost': 'Cost Tracker',
'nav.logs': 'Logs',
@ -199,6 +232,7 @@ const translations: Record<Locale, Record<string, string>> = {
'nav.cron': 'Zamanlanmis Gorevler',
'nav.integrations': 'Entegrasyonlar',
'nav.memory': 'Hafiza',
'nav.devices': 'Cihazlar',
'nav.config': 'Yapilandirma',
'nav.cost': 'Maliyet Takibi',
'nav.logs': 'Kayitlar',
@ -373,6 +407,195 @@ const translations: Record<Locale, Record<string, string>> = {
'health.uptime': 'Calisma Suresi',
'health.updated_at': 'Son Guncelleme',
},
'zh-CN': {
// Navigation
'nav.dashboard': '仪表盘',
'nav.agent': '智能体',
'nav.tools': '工具',
'nav.cron': '定时任务',
'nav.integrations': '集成',
'nav.memory': '记忆',
'nav.devices': '设备',
'nav.config': '配置',
'nav.cost': '成本追踪',
'nav.logs': '日志',
'nav.doctor': '诊断',
// Dashboard
'dashboard.title': '仪表盘',
'dashboard.provider': '提供商',
'dashboard.model': '模型',
'dashboard.uptime': '运行时长',
'dashboard.temperature': '温度',
'dashboard.gateway_port': '网关端口',
'dashboard.locale': '语言区域',
'dashboard.memory_backend': '记忆后端',
'dashboard.paired': '已配对',
'dashboard.channels': '渠道',
'dashboard.health': '健康状态',
'dashboard.status': '状态',
'dashboard.overview': '总览',
'dashboard.system_info': '系统信息',
'dashboard.quick_actions': '快捷操作',
// Agent / Chat
'agent.title': '智能体聊天',
'agent.send': '发送',
'agent.placeholder': '输入消息...',
'agent.connecting': '连接中...',
'agent.connected': '已连接',
'agent.disconnected': '已断开连接',
'agent.reconnecting': '重连中...',
'agent.thinking': '思考中...',
'agent.tool_call': '工具调用',
'agent.tool_result': '工具结果',
// Tools
'tools.title': '可用工具',
'tools.name': '名称',
'tools.description': '描述',
'tools.parameters': '参数',
'tools.search': '搜索工具...',
'tools.empty': '暂无可用工具。',
'tools.count': '工具总数',
// Cron
'cron.title': '定时任务',
'cron.add': '添加任务',
'cron.delete': '删除',
'cron.enable': '启用',
'cron.disable': '禁用',
'cron.name': '名称',
'cron.command': '命令',
'cron.schedule': '计划',
'cron.next_run': '下次运行',
'cron.last_run': '上次运行',
'cron.last_status': '上次状态',
'cron.enabled': '已启用',
'cron.empty': '暂无定时任务。',
'cron.confirm_delete': '确定要删除此任务吗?',
// Integrations
'integrations.title': '集成',
'integrations.available': '可用',
'integrations.active': '已激活',
'integrations.coming_soon': '即将推出',
'integrations.category': '分类',
'integrations.status': '状态',
'integrations.search': '搜索集成...',
'integrations.empty': '未找到集成。',
'integrations.activate': '激活',
'integrations.deactivate': '停用',
// Memory
'memory.title': '记忆存储',
'memory.search': '搜索记忆...',
'memory.add': '存储记忆',
'memory.delete': '删除',
'memory.key': '键',
'memory.content': '内容',
'memory.category': '分类',
'memory.timestamp': '时间戳',
'memory.session': '会话',
'memory.score': '评分',
'memory.empty': '未找到记忆条目。',
'memory.confirm_delete': '确定要删除此记忆条目吗?',
'memory.all_categories': '全部分类',
// Config
'config.title': '配置',
'config.save': '保存',
'config.reset': '重置',
'config.saved': '配置保存成功。',
'config.error': '配置保存失败。',
'config.loading': '配置加载中...',
'config.editor_placeholder': 'TOML 配置...',
// Cost
'cost.title': '成本追踪',
'cost.session': '会话成本',
'cost.daily': '每日成本',
'cost.monthly': '每月成本',
'cost.total_tokens': 'Token 总数',
'cost.request_count': '请求数',
'cost.by_model': '按模型统计成本',
'cost.model': '模型',
'cost.tokens': 'Token',
'cost.requests': '请求',
'cost.usd': '成本USD',
// Logs
'logs.title': '实时日志',
'logs.clear': '清空',
'logs.pause': '暂停',
'logs.resume': '继续',
'logs.filter': '筛选日志...',
'logs.empty': '暂无日志条目。',
'logs.connected': '已连接到事件流。',
'logs.disconnected': '与事件流断开连接。',
// Doctor
'doctor.title': '系统诊断',
'doctor.run': '运行诊断',
'doctor.running': '正在运行诊断...',
'doctor.ok': '正常',
'doctor.warn': '警告',
'doctor.error': '错误',
'doctor.severity': '严重级别',
'doctor.category': '分类',
'doctor.message': '消息',
'doctor.empty': '尚未运行诊断。',
'doctor.summary': '诊断摘要',
// Auth / Pairing
'auth.pair': '设备配对',
'auth.pairing_code': '配对码',
'auth.pair_button': '配对',
'auth.logout': '退出登录',
'auth.pairing_success': '配对成功!',
'auth.pairing_failed': '配对失败,请重试。',
'auth.enter_code': '输入配对码以连接到智能体。',
// Common
'common.loading': '加载中...',
'common.error': '发生错误。',
'common.retry': '重试',
'common.cancel': '取消',
'common.confirm': '确认',
'common.save': '保存',
'common.delete': '删除',
'common.edit': '编辑',
'common.close': '关闭',
'common.yes': '是',
'common.no': '否',
'common.search': '搜索...',
'common.no_data': '暂无数据。',
'common.refresh': '刷新',
'common.back': '返回',
'common.actions': '操作',
'common.name': '名称',
'common.description': '描述',
'common.status': '状态',
'common.created': '创建时间',
'common.updated': '更新时间',
// Health
'health.title': '系统健康',
'health.component': '组件',
'health.status': '状态',
'health.last_ok': '最近正常',
'health.last_error': '最近错误',
'health.restart_count': '重启次数',
'health.pid': '进程 ID',
'health.uptime': '运行时长',
'health.updated_at': '最后更新',
},
ja: {},
ru: {},
fr: {},
vi: {},
el: {},
};
// ---------------------------------------------------------------------------
@ -413,6 +636,21 @@ export function tLocale(key: string, locale: Locale): string {
// React hook
// ---------------------------------------------------------------------------
export function coerceLocale(locale: string | undefined): Locale {
if (!locale) return 'en';
if (KNOWN_LOCALES.includes(locale as Locale)) return locale as Locale;
const lowered = locale.toLowerCase();
if (lowered.startsWith('tr')) return 'tr';
if (lowered === 'zh' || lowered.startsWith('zh-')) return 'zh-CN';
if (lowered === 'ja' || lowered.startsWith('ja-')) return 'ja';
if (lowered === 'ru' || lowered.startsWith('ru-')) return 'ru';
if (lowered === 'fr' || lowered.startsWith('fr-')) return 'fr';
if (lowered === 'vi' || lowered.startsWith('vi-')) return 'vi';
if (lowered === 'el' || lowered.startsWith('el-')) return 'el';
return 'en';
}
/**
* React hook that fetches the locale from /api/status on mount and keeps the
* i18n module in sync. Returns the current locale and a `t` helper bound to it.
@ -426,9 +664,7 @@ export function useLocale(): { locale: Locale; t: (key: string) => string } {
getStatus()
.then((status) => {
if (cancelled) return;
const detected = status.locale?.toLowerCase().startsWith('tr')
? 'tr'
: 'en';
const detected = coerceLocale(status.locale);
setLocale(detected);
setLocaleState(detected);
})

View File

@ -52,6 +52,7 @@ export class SSEClient {
connect(): void {
this.intentionallyClosed = false;
this.clearReconnectTimer();
this.controller = new AbortController();
const token = getToken();

9
web/src/lib/wasm.test.ts Normal file
View File

@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { isWasmSupported } from './wasm';
describe('isWasmSupported', () => {
it('returns a boolean without throwing', () => {
expect(() => isWasmSupported()).not.toThrow();
expect(typeof isWasmSupported()).toBe('boolean');
});
});

31
web/src/lib/wasm.ts Normal file
View File

@ -0,0 +1,31 @@
// Canonical tiny WASM module header for capability checks.
const wasmProbeBytes = Uint8Array.of(
0x00,
0x61,
0x73,
0x6d,
0x01,
0x00,
0x00,
0x00,
);
export function isWasmSupported(): boolean {
try {
if (typeof WebAssembly !== 'object') {
return false;
}
if (typeof WebAssembly.Module !== 'function') {
return false;
}
if (typeof WebAssembly.Instance !== 'function') {
return false;
}
const module = new WebAssembly.Module(wasmProbeBytes);
const instance = new WebAssembly.Instance(module);
return instance instanceof WebAssembly.Instance;
} catch {
return false;
}
}

View File

@ -19,6 +19,7 @@ export interface WebSocketClientOptions {
const DEFAULT_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const WS_SESSION_STORAGE_KEY = 'zeroclaw.ws.session_id';
export class WebSocketClient {
private ws: WebSocket | null = null;
@ -35,6 +36,7 @@ export class WebSocketClient {
private readonly reconnectDelay: number;
private readonly maxReconnectDelay: number;
private readonly autoReconnect: boolean;
private readonly sessionId: string;
constructor(options: WebSocketClientOptions = {}) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -44,6 +46,7 @@ export class WebSocketClient {
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
this.autoReconnect = options.autoReconnect ?? true;
this.currentDelay = this.reconnectDelay;
this.sessionId = this.resolveSessionId();
}
/** Open the WebSocket connection. */
@ -52,9 +55,13 @@ export class WebSocketClient {
this.clearReconnectTimer();
const token = getToken();
const url = `${this.baseUrl}/ws/chat${token ? `?token=${encodeURIComponent(token)}` : ''}`;
const url = `${this.baseUrl}/ws/chat?session_id=${encodeURIComponent(this.sessionId)}`;
const protocols = ['zeroclaw.v1'];
if (token) {
protocols.push(`bearer.${token}`);
}
this.ws = new WebSocket(url);
this.ws = new WebSocket(url, protocols);
this.ws.onopen = () => {
this.currentDelay = this.reconnectDelay;
@ -122,4 +129,17 @@ export class WebSocketClient {
this.reconnectTimer = null;
}
}
private resolveSessionId(): string {
const existing = window.localStorage.getItem(WS_SESSION_STORAGE_KEY);
if (existing && /^[A-Za-z0-9_-]{1,128}$/.test(existing)) {
return existing;
}
const generated =
globalThis.crypto?.randomUUID?.().replace(/-/g, '_') ??
`sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
window.localStorage.setItem(WS_SESSION_STORAGE_KEY, generated);
return generated;
}
}

View File

@ -10,6 +10,20 @@ interface ChatMessage {
timestamp: Date;
}
let fallbackMessageIdCounter = 0;
const EMPTY_DONE_FALLBACK =
'Tool execution completed, but no final response text was returned.';
function makeMessageId(): string {
const uuid = globalThis.crypto?.randomUUID?.();
if (uuid) return uuid;
fallbackMessageIdCounter += 1;
return `msg_${Date.now().toString(36)}_${fallbackMessageIdCounter.toString(36)}_${Math.random()
.toString(36)
.slice(2, 10)}`;
}
export default function AgentChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
@ -40,6 +54,22 @@ export default function AgentChat() {
ws.onMessage = (msg: WsMessage) => {
switch (msg.type) {
case 'history': {
const restored: ChatMessage[] = (msg.messages ?? [])
.filter((entry) => entry.content?.trim())
.map((entry): ChatMessage => ({
id: makeMessageId(),
role: entry.role === 'user' ? 'user' : 'agent',
content: entry.content.trim(),
timestamp: new Date(),
}));
setMessages(restored);
setTyping(false);
pendingContentRef.current = '';
break;
}
case 'chunk':
setTyping(true);
pendingContentRef.current += msg.content ?? '';
@ -47,18 +77,19 @@ export default function AgentChat() {
case 'message':
case 'done': {
const content = msg.full_response ?? msg.content ?? pendingContentRef.current;
if (content) {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'agent',
content,
timestamp: new Date(),
},
]);
}
const content = (msg.full_response ?? msg.content ?? pendingContentRef.current ?? '').trim();
const finalContent = content || EMPTY_DONE_FALLBACK;
setMessages((prev) => [
...prev,
{
id: makeMessageId(),
role: 'agent',
content: finalContent,
timestamp: new Date(),
},
]);
pendingContentRef.current = '';
setTyping(false);
break;
@ -68,7 +99,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: makeMessageId(),
role: 'agent',
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
@ -80,7 +111,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: makeMessageId(),
role: 'agent',
content: `[Tool Result] ${msg.output ?? ''}`,
timestamp: new Date(),
@ -92,7 +123,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: makeMessageId(),
role: 'agent',
content: `[Error] ${msg.message ?? 'Unknown error'}`,
timestamp: new Date(),
@ -123,7 +154,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: makeMessageId(),
role: 'user',
content: trimmed,
timestamp: new Date(),
@ -150,7 +181,7 @@ export default function AgentChat() {
};
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
<div className="flex min-h-[28rem] flex-col h-[calc(100dvh-8.5rem)]">
{/* Connection status bar */}
{error && (
<div className="px-4 py-2 bg-red-900/30 border-b border-red-700 flex items-center gap-2 text-sm text-red-300">
@ -228,7 +259,7 @@ export default function AgentChat() {
</div>
{/* Input area */}
<div className="border-t border-gray-800 bg-gray-900 p-4">
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex items-center gap-3 max-w-4xl mx-auto">
<div className="flex-1 relative">
<input

View File

@ -1,50 +1,61 @@
import { useState, useEffect } from 'react';
import {
Settings,
Save,
CheckCircle,
AlertTriangle,
ShieldAlert,
FileText,
SlidersHorizontal,
} from 'lucide-react';
import { getConfig, putConfig } from '@/lib/api';
import { useConfigForm, type EditorMode } from '@/components/config/useConfigForm';
import ConfigFormEditor from '@/components/config/ConfigFormEditor';
import ConfigRawEditor from '@/components/config/ConfigRawEditor';
function ModeTab({
mode,
active,
icon: Icon,
label,
onClick,
}: {
mode: EditorMode;
active: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
active
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'
}`}
aria-pressed={active}
data-mode={mode}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
);
}
export default function Config() {
const [config, setConfig] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
getConfig()
.then((data) => {
// The API may return either a raw string or a JSON string
setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
await putConfig(config);
setSuccess('Configuration saved successfully.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
setSaving(false);
}
};
// Auto-dismiss success after 4 seconds
useEffect(() => {
if (!success) return;
const timer = setTimeout(() => setSuccess(null), 4000);
return () => clearTimeout(timer);
}, [success]);
const {
loading,
saving,
error,
success,
mode,
rawToml,
setMode,
getFieldValue,
setFieldValue,
isFieldMasked,
setRawToml,
save,
} = useConfigForm();
if (loading) {
return (
@ -62,14 +73,34 @@ export default function Config() {
<Settings className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Configuration</h2>
</div>
<button
onClick={handleSave}
disabled={saving}
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'}
</button>
<div className="flex items-center gap-3">
{/* Mode toggle */}
<div className="flex items-center gap-1 bg-gray-900 border border-gray-800 rounded-lg p-0.5">
<ModeTab
mode="form"
active={mode === 'form'}
icon={SlidersHorizontal}
label="Form"
onClick={() => setMode('form')}
/>
<ModeTab
mode="raw"
active={mode === 'raw'}
icon={FileText}
label="Raw"
onClick={() => setMode('raw')}
/>
</div>
<button
onClick={save}
disabled={saving}
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'}
</button>
</div>
</div>
{/* Sensitive fields note */}
@ -80,8 +111,9 @@ export default function Config() {
Sensitive fields are masked
</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.
{mode === 'form'
? 'Masked fields show "Configured (masked)" as a placeholder. Leave them untouched to preserve existing values, or enter a new value to update.'
: 'API keys, tokens, and passwords are hidden for security. To update a masked field, replace the entire masked value with your new value.'}
</p>
</div>
</div>
@ -102,24 +134,20 @@ export default function Config() {
</div>
)}
{/* Config Editor */}
<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
</span>
<span className="text-xs text-gray-500">
{config.split('\n').length} lines
</span>
</div>
<textarea
value={config}
onChange={(e) => setConfig(e.target.value)}
spellCheck={false}
className="w-full min-h-[500px] bg-gray-950 text-gray-200 font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
style={{ tabSize: 4 }}
{/* Editor */}
{mode === 'form' ? (
<ConfigFormEditor
getFieldValue={getFieldValue}
setFieldValue={setFieldValue}
isFieldMasked={isFieldMasked}
/>
</div>
) : (
<ConfigRawEditor
rawToml={rawToml}
onChange={setRawToml}
disabled={saving}
/>
)}
</div>
);
}

View File

@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Dashboard from './Dashboard';
import { getCost, getStatus } from '@/lib/api';
import type { CostSummary, StatusResponse } from '@/types/api';
vi.mock('@/lib/api', () => ({
getStatus: vi.fn(),
getCost: vi.fn(),
}));
const mockedGetStatus = vi.mocked(getStatus);
const mockedGetCost = vi.mocked(getCost);
const statusFixture: StatusResponse = {
provider: 'openai',
model: 'gpt-5.2',
temperature: 0.4,
uptime_seconds: 68420,
gateway_port: 42617,
locale: 'en-US',
memory_backend: 'sqlite',
paired: true,
channels: {
telegram: true,
discord: false,
whatsapp: true,
},
health: {
uptime_seconds: 68420,
updated_at: '2026-03-02T19:34:29.678544+00:00',
pid: 4242,
components: {
gateway: {
status: 'ok',
updated_at: '2026-03-02T19:34:29.678544+00:00',
last_ok: '2026-03-02T19:34:29.678544+00:00',
last_error: null,
restart_count: 0,
},
},
},
};
const costFixture: CostSummary = {
session_cost_usd: 0.0842,
daily_cost_usd: 1.3026,
monthly_cost_usd: 14.9875,
total_tokens: 182342,
request_count: 426,
by_model: {
'gpt-5.2': {
model: 'gpt-5.2',
cost_usd: 11.4635,
total_tokens: 141332,
request_count: 292,
},
},
};
afterEach(() => {
vi.clearAllMocks();
});
describe('Dashboard', () => {
it('renders with API data and supports collapsing every dashboard section', async () => {
mockedGetStatus.mockResolvedValue(statusFixture);
mockedGetCost.mockResolvedValue(costFixture);
render(<Dashboard />);
expect(await screen.findByText('Electric Runtime Dashboard')).toBeInTheDocument();
expect(await screen.findByText('openai')).toBeInTheDocument();
const sectionButtons = [
screen.getByRole('button', { name: /Cost Pulse/i }),
screen.getByRole('button', { name: /Channel Activity/i }),
screen.getByRole('button', { name: /Component Health/i }),
];
for (const sectionButton of sectionButtons) {
expect(sectionButton).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(sectionButton);
await waitFor(() => {
expect(sectionButton).toHaveAttribute('aria-expanded', 'false');
});
await userEvent.click(sectionButton);
await waitFor(() => {
expect(sectionButton).toHaveAttribute('aria-expanded', 'true');
});
}
});
});

View File

@ -1,15 +1,37 @@
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';
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 +50,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 +64,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 : 'Unknown dashboard load 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">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 +170,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]">ZeroClaw Command Deck</p>
<h1 className="mt-2 text-2xl font-semibold tracking-[0.03em] text-white md:text-3xl">
Electric Runtime Dashboard
</h1>
<p className="mt-2 max-w-2xl text-sm text-[#b3cbf8] md:text-base">
Real-time telemetry, cost pulse, and operations status in a single collapsible surface.
</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" />
Live Gateway
</span>
<span className="status-pill">
<ShieldCheck className="h-3.5 w-3.5" />
{status.paired ? 'Paired' : '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>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 ?? 'Unknown'}</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>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">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>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>Memory Backend</span>
</div>
<p className="metric-value mt-3 capitalize">{status.memory_backend}</p>
<p className="metric-sub mt-1">{status.paired ? 'Pairing active' : 'No paired devices'}</p>
</article>
</section>
<div className="space-y-4">
<CollapsibleSection
title="Cost Pulse"
subtitle="Session, daily, and monthly runtime spend"
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: 'Session', value: cost.session_cost_usd },
{ label: 'Daily', value: cost.daily_cost_usd },
{ label: '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>Total Tokens</span>
<strong>{cost.total_tokens.toLocaleString()}</strong>
</div>
<div className="metric-pill">
<span>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="Channel Activity"
subtitle="Live integrations and route connectivity"
icon={Radio}
sectionKey="channels"
openState={sectionsOpen}
onToggle={toggleSection}
>
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-[#8aa8df]">No channels configured.</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">
<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 ? 'Active' : '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="Component Health"
subtitle="Runtime heartbeat and restart awareness"
icon={Activity}
sectionKey="health"
openState={sectionsOpen}
onToggle={toggleSection}
>
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-[#8aa8df]">No component health is currently available.</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">
Restarts: {component.restart_count}
</p>
)}
</div>
))
)}
</div>
</div>
))}
</div>
)}
</CollapsibleSection>
</div>
</div>
);

170
web/src/pages/Devices.tsx Normal file
View File

@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
import { Smartphone, RefreshCw, ShieldX } from 'lucide-react';
import type { PairedDevice } from '@/types/api';
import { getPairedDevices, revokePairedDevice } from '@/lib/api';
function formatDate(value: string | null): string {
if (!value) return 'Unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
export default function Devices() {
const [devices, setDevices] = useState<PairedDevice[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<string | null>(null);
const loadDevices = async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const data = await getPairedDevices();
setDevices(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load paired devices');
} finally {
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
};
useEffect(() => {
void loadDevices(false);
}, []);
const handleRevoke = async (id: string) => {
try {
await revokePairedDevice(id);
setDevices((prev) => prev.filter((device) => device.id !== id));
setPendingRevoke(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to revoke paired device');
setPendingRevoke(null);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Paired Devices ({devices.length})
</h2>
</div>
<button
onClick={() => {
void loadDevices(true);
}}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-60"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{error && (
<div className="rounded-lg border border-red-700 bg-red-900/30 p-3 text-sm text-red-300">
{error}
</div>
)}
{loading ? (
<div className="flex h-32 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : devices.length === 0 ? (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
<ShieldX className="mx-auto mb-3 h-10 w-10 text-gray-600" />
<p className="text-gray-400">No paired devices found.</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 text-left font-medium text-gray-400">
Device ID
</th>
<th className="px-4 py-3 text-left font-medium text-gray-400">
Paired By
</th>
<th className="px-4 py-3 text-left font-medium text-gray-400">
Created
</th>
<th className="px-4 py-3 text-left font-medium text-gray-400">
Last Seen
</th>
<th className="px-4 py-3 text-right font-medium text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
>
<td className="px-4 py-3 font-mono text-xs text-white">
{device.token_fingerprint}
</td>
<td className="px-4 py-3 text-gray-300">
{device.paired_by ?? 'Unknown'}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
{formatDate(device.created_at)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
{formatDate(device.last_seen_at)}
</td>
<td className="px-4 py-3 text-right">
{pendingRevoke === device.id ? (
<div className="inline-flex items-center gap-2">
<span className="text-xs text-red-400">Revoke?</span>
<button
onClick={() => {
void handleRevoke(device.id);
}}
className="text-xs font-medium text-red-400 hover:text-red-300"
>
Yes
</button>
<button
onClick={() => setPendingRevoke(null)}
className="text-xs font-medium text-gray-400 hover:text-white"
>
No
</button>
</div>
) : (
<button
onClick={() => setPendingRevoke(device.id)}
className="text-xs font-medium text-red-400 hover:text-red-300"
>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,19 @@
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 { Link } from 'react-router-dom';
import { Puzzle, Check, Zap, Clock, KeyRound, X } from 'lucide-react';
import ChatChannelsGuide from '@/components/integrations/ChatChannelsGuide';
import type {
Integration,
IntegrationCredentialsField,
IntegrationSettingsEntry,
StatusResponse,
} from '@/types/api';
import {
getIntegrations,
getIntegrationSettings,
getStatus,
putIntegrationCredentials,
} from '@/lib/api';
function statusBadge(status: Integration['status']) {
switch (status) {
@ -26,19 +38,368 @@ function statusBadge(status: Integration['status']) {
}
}
function formatCategory(category: string): string {
if (!category) return category;
return category
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/Ai/g, 'AI');
}
const SELECT_KEEP = '__keep__';
const SELECT_CUSTOM = '__custom__';
const SELECT_CLEAR = '__clear__';
const FALLBACK_MODEL_OPTIONS: Record<string, string[]> = {
openrouter: ['anthropic/claude-sonnet-4-6', 'openai/gpt-5.2', 'google/gemini-3.1-pro'],
anthropic: ['claude-sonnet-4-6', 'claude-opus-4-6'],
openai: ['gpt-5.2', 'gpt-5.2-codex', 'gpt-4o'],
google: ['google/gemini-3.1-pro', 'google/gemini-3-flash', 'google/gemini-2.5-pro'],
deepseek: ['deepseek/deepseek-reasoner', 'deepseek/deepseek-chat'],
xai: ['x-ai/grok-4', 'x-ai/grok-3'],
mistral: ['mistral-large-latest', 'codestral-latest', 'mistral-small-latest'],
perplexity: ['sonar-pro', 'sonar-reasoning-pro', 'sonar'],
vercel: ['openai/gpt-5.2', 'anthropic/claude-sonnet-4-6', 'google/gemini-3.1-pro'],
bedrock: ['anthropic.claude-sonnet-4-5-20250929-v1:0', 'anthropic.claude-opus-4-6-v1:0'],
groq: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768'],
together: [
'meta-llama/Llama-3.3-70B-Instruct-Turbo',
'Qwen/Qwen2.5-72B-Instruct-Turbo',
'deepseek-ai/DeepSeek-R1-Distill-Llama-70B',
],
cohere: ['command-r-plus-08-2024', 'command-r-08-2024'],
};
function customModelFormatHint(integrationId: string): string {
if (integrationId === 'openrouter' || integrationId === 'vercel') {
return 'Format: anthropic/claude-sonnet-4-6';
}
return 'Format: claude-sonnet-4-6 (or provider/model when required)';
}
function modelOptionsForField(
integrationId: string,
field: IntegrationCredentialsField,
): string[] {
if (field.key !== 'default_model') return field.options ?? [];
if (field.options?.length) return field.options;
return FALLBACK_MODEL_OPTIONS[integrationId] ?? [];
}
export default function Integrations() {
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [settingsByName, setSettingsByName] = useState<
Record<string, IntegrationSettingsEntry>
>({});
const [settingsRevision, setSettingsRevision] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeCategory, setActiveCategory] = useState<string>('all');
const [activeEditor, setActiveEditor] = useState<IntegrationSettingsEntry | null>(
null,
);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [customFieldValues, setCustomFieldValues] = useState<Record<string, string>>({});
const [dirtyFields, setDirtyFields] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState<string | null>(null);
const [runtimeStatus, setRuntimeStatus] = useState<Pick<StatusResponse, 'model'> | null>(null);
const [activeAiIntegrationId, setActiveAiIntegrationId] = useState<string | null>(null);
const [quickModelDrafts, setQuickModelDrafts] = useState<Record<string, string>>({});
const [quickModelSavingId, setQuickModelSavingId] = useState<string | null>(null);
const [quickModelError, setQuickModelError] = useState<string | null>(null);
const buildInitialFieldValues = (integration: IntegrationSettingsEntry) =>
integration.fields.reduce<Record<string, string>>((acc, field) => {
if (modelOptionsForField(integration.id, field).length > 0) {
acc[field.key] = field.has_value ? SELECT_KEEP : '';
} else {
acc[field.key] = '';
}
return acc;
}, {});
const modelFieldFor = (integration: IntegrationSettingsEntry) =>
integration.fields.find((field) => field.key === 'default_model');
const fallbackModelFor = (integration: IntegrationSettingsEntry): string | null => {
const modelField = modelFieldFor(integration);
if (!modelField) return null;
return modelOptionsForField(integration.id, modelField)[0] ?? null;
};
const modelValueFor = (
integration: IntegrationSettingsEntry,
isActiveDefaultProvider: boolean,
): string | null => {
if (isActiveDefaultProvider && runtimeStatus?.model?.trim()) {
return runtimeStatus.model.trim();
}
const fieldModel = modelFieldFor(integration)?.current_value?.trim();
if (fieldModel) {
return fieldModel;
}
return null;
};
const activeAiIntegration = Object.values(settingsByName).find(
(integration) => integration.id === activeAiIntegrationId,
);
const loadData = async (
showLoadingState = true,
): Promise<Record<string, IntegrationSettingsEntry> | null> => {
if (showLoadingState) {
setLoading(true);
}
setError(null);
try {
const [integrationList, settings, status] = await Promise.all([
getIntegrations(),
getIntegrationSettings(),
getStatus().catch(() => null),
]);
const nextSettingsByName = settings.integrations.reduce<
Record<string, IntegrationSettingsEntry>
>((acc, item) => {
acc[item.name] = item;
return acc;
}, {});
setIntegrations(integrationList);
setSettingsRevision(settings.revision);
setSettingsByName(nextSettingsByName);
setActiveAiIntegrationId(settings.active_default_provider_integration_id ?? null);
setRuntimeStatus(status ? { model: status.model } : null);
return nextSettingsByName;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load integrations');
setActiveAiIntegrationId(null);
setRuntimeStatus(null);
return null;
} finally {
if (showLoadingState) {
setLoading(false);
}
}
};
useEffect(() => {
getIntegrations()
.then(setIntegrations)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
void loadData();
}, []);
useEffect(() => {
if (!saveSuccess) return;
const timer = setTimeout(() => setSaveSuccess(null), 4000);
return () => clearTimeout(timer);
}, [saveSuccess]);
useEffect(() => {
if (!activeEditor) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeEditor();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [activeEditor, saving]);
const openEditor = (integration: IntegrationSettingsEntry) => {
setActiveEditor(integration);
setFieldValues(buildInitialFieldValues(integration));
setCustomFieldValues({});
setDirtyFields({});
setSaveError(null);
};
const closeEditor = () => {
if (saving) return;
setActiveEditor(null);
setFieldValues({});
setCustomFieldValues({});
setDirtyFields({});
setSaveError(null);
};
const updateField = (key: string, value: string) => {
setFieldValues((prev) => ({ ...prev, [key]: value }));
setDirtyFields((prev) => ({ ...prev, [key]: true }));
};
const updateCustomField = (key: string, value: string) => {
setCustomFieldValues((prev) => ({ ...prev, [key]: value }));
setDirtyFields((prev) => ({ ...prev, [key]: true }));
};
const saveCredentials = async () => {
if (!activeEditor) return;
setSaveError(null);
setQuickModelError(null);
const payload: Record<string, string> = {};
for (const field of activeEditor.fields) {
const value = fieldValues[field.key] ?? '';
const isDirty = !!dirtyFields[field.key];
const isSelectField = modelOptionsForField(activeEditor.id, field).length > 0;
let resolvedValue = value;
if (isSelectField) {
if (value === SELECT_KEEP) {
if (field.required && !field.has_value) {
setSaveError(`${field.label} is required.`);
return;
}
if (isDirty) {
continue;
}
} else if (value === SELECT_CUSTOM) {
resolvedValue = customFieldValues[field.key] ?? '';
} else if (value === SELECT_CLEAR) {
resolvedValue = '';
}
}
const trimmed = resolvedValue.trim();
if (isSelectField && value === SELECT_CUSTOM && !trimmed) {
setSaveError(`Enter a custom value for ${field.label} or choose a recommended model.`);
return;
}
if (field.required && !trimmed && !field.has_value) {
setSaveError(`${field.label} is required.`);
return;
}
if (isDirty) {
if (isSelectField && value === SELECT_KEEP) {
continue;
}
payload[field.key] = resolvedValue;
}
}
if (
Object.keys(payload).length === 0 &&
!activeEditor.activates_default_provider
) {
setSaveError('No changes to save.');
return;
}
if (
activeEditor.activates_default_provider &&
activeAiIntegrationId &&
activeEditor.id !== activeAiIntegrationId
) {
const currentProvider = activeAiIntegration?.name ?? 'current provider';
const confirmed = window.confirm(
`Switch default AI provider from ${currentProvider} to ${activeEditor.name}?`,
);
if (!confirmed) {
return;
}
}
setSaving(true);
try {
await putIntegrationCredentials(activeEditor.id, {
revision: settingsRevision,
fields: payload,
});
await loadData(false);
setSaveSuccess(`${activeEditor.name} credentials saved.`);
closeEditor();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save credentials';
if (message.includes('API 409')) {
const refreshed = await loadData(false);
if (refreshed) {
const latestEditor = refreshed[activeEditor.name];
if (latestEditor) {
setActiveEditor(latestEditor);
setFieldValues(buildInitialFieldValues(latestEditor));
setCustomFieldValues({});
setDirtyFields({});
}
}
setSaveError(
'Configuration changed elsewhere. Refreshed latest settings; re-enter values and save again.',
);
} else {
setSaveError(message);
}
} finally {
setSaving(false);
}
};
const saveQuickModel = async (
integration: IntegrationSettingsEntry,
targetModel: string,
currentModel: string,
isActiveDefaultProvider: boolean,
) => {
const trimmedTarget = targetModel.trim();
if (!trimmedTarget || trimmedTarget === currentModel) {
return;
}
if (
activeAiIntegrationId &&
!isActiveDefaultProvider &&
integration.id !== activeAiIntegrationId
) {
const currentProvider = activeAiIntegration?.name ?? 'current provider';
const confirmed = window.confirm(
`Switch default AI provider from ${currentProvider} to ${integration.name} and set model to ${trimmedTarget}?`,
);
if (!confirmed) {
return;
}
}
setQuickModelSavingId(integration.id);
setQuickModelError(null);
setSaveError(null);
try {
await putIntegrationCredentials(integration.id, {
revision: settingsRevision,
fields: {
default_model: trimmedTarget,
},
});
await loadData(false);
setSaveSuccess(`Model updated to ${trimmedTarget} for ${integration.name}.`);
setQuickModelDrafts((prev) => {
const next = { ...prev };
delete next[integration.id];
return next;
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update model';
if (message.includes('API 409')) {
await loadData(false);
setQuickModelError(
'Configuration changed elsewhere. Refreshed latest settings; choose the model again.',
);
} else {
setQuickModelError(message);
}
} finally {
setQuickModelSavingId(null);
}
};
const categories = [
'all',
...Array.from(new Set(integrations.map((i) => i.category))).sort(),
@ -85,6 +446,20 @@ export default function Integrations() {
</h2>
</div>
{saveSuccess && (
<div className="rounded-lg bg-green-900/30 border border-green-700 p-3 text-sm text-green-300">
{saveSuccess}
</div>
)}
{quickModelError && (
<div className="rounded-lg bg-red-900/30 border border-red-700 p-3 text-sm text-red-300">
{quickModelError}
</div>
)}
<ChatChannelsGuide />
{/* Category Filter Tabs */}
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
@ -97,7 +472,7 @@ export default function Integrations() {
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:bg-gray-800 hover:text-white'
}`}
>
{cat}
{cat === 'all' ? 'All' : formatCategory(cat)}
</button>
))}
</div>
@ -114,16 +489,56 @@ export default function Integrations() {
.map(([category, items]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3 capitalize">
{category}
{formatCategory(category)}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{items.map((integration) => {
const badge = statusBadge(integration.status);
const BadgeIcon = badge.icon;
const editable = settingsByName[integration.name];
const isAiIntegration = !!editable?.activates_default_provider;
const isActiveDefaultProvider =
!!editable &&
isAiIntegration &&
editable.id === activeAiIntegrationId;
const modelField = editable ? modelFieldFor(editable) : undefined;
const modelOptions =
editable && modelField ? modelOptionsForField(editable.id, modelField) : [];
const currentModel =
editable && isAiIntegration
? modelValueFor(editable, isActiveDefaultProvider)
: null;
const fallbackModel =
editable && isAiIntegration ? fallbackModelFor(editable) : null;
const modelSummary = currentModel
? currentModel
: fallbackModel
? `default: ${fallbackModel}`
: 'default';
const modelBaseline = currentModel ?? fallbackModel ?? '';
const quickDraft = editable
? quickModelDrafts[editable.id] ?? modelBaseline
: '';
const quickOptions = [
...(currentModel && !modelOptions.includes(currentModel)
? [currentModel]
: []),
...modelOptions,
];
const showQuickModelControls =
!!editable &&
editable.configured &&
isAiIntegration &&
quickOptions.length > 0;
return (
<div
key={integration.name}
className="bg-gray-900 rounded-xl border border-gray-800 p-5 hover:border-gray-700 transition-colors"
className={`bg-gray-900 rounded-xl border p-5 transition-colors ${
isActiveDefaultProvider
? 'border-green-700/70 bg-gradient-to-b from-green-950/20 to-gray-900'
: 'border-gray-800 hover:border-gray-700'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -134,13 +549,106 @@ export default function Integrations() {
{integration.description}
</p>
</div>
<span
className={`flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${badge.classes}`}
>
<BadgeIcon className="h-3 w-3" />
{badge.label}
</span>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{isAiIntegration && editable?.configured && (
<span
className={`flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${
isActiveDefaultProvider
? 'bg-emerald-900/40 text-emerald-300 border-emerald-700/60'
: 'bg-gray-800 text-gray-300 border-gray-700'
}`}
>
{isActiveDefaultProvider ? 'Default' : 'Configured'}
</span>
)}
<span
className={`flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${badge.classes}`}
>
<BadgeIcon className="h-3 w-3" />
{badge.label}
</span>
</div>
</div>
{editable && isAiIntegration && editable.configured && (
<div className="mt-3 rounded-lg border border-gray-800 bg-gray-950/50 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] uppercase tracking-wider text-gray-500">
Current model
</span>
<span className="text-xs text-gray-200 truncate" title={modelSummary}>
{modelSummary}
</span>
</div>
{showQuickModelControls && editable && (
<div className="space-y-1">
<div className="flex items-center gap-2">
<select
value={quickDraft}
onChange={(e) =>
setQuickModelDrafts((prev) => ({
...prev,
[editable.id]: e.target.value,
}))
}
disabled={quickModelSavingId === editable.id}
className="min-w-0 flex-1 px-2.5 py-1.5 rounded-lg bg-gray-950 border border-gray-700 text-xs text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
>
{quickOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<button
onClick={() =>
editable &&
void saveQuickModel(
editable,
quickDraft,
modelBaseline,
isActiveDefaultProvider,
)
}
disabled={
quickModelSavingId === editable.id ||
!quickDraft ||
quickDraft === modelBaseline
}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
>
{quickModelSavingId === editable.id ? 'Saving...' : 'Apply'}
</button>
</div>
<p className="text-[11px] text-gray-500">
For custom model IDs, use Edit Keys.
</p>
</div>
)}
</div>
)}
{editable && (
<div className="mt-4 pt-4 border-t border-gray-800 flex items-center justify-between gap-3">
<div className="text-xs text-gray-400">
{editable.configured
? editable.activates_default_provider
? isActiveDefaultProvider
? 'Default provider configured'
: 'Provider configured'
: 'Credentials configured'
: 'Credentials not configured'}
</div>
<button
onClick={() => openEditor(editable)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-blue-700/70 bg-blue-900/30 hover:bg-blue-900/50 text-blue-300 text-xs font-medium transition-colors"
>
<KeyRound className="h-3.5 w-3.5" />
{editable.configured ? 'Edit Keys' : 'Configure'}
</button>
</div>
)}
</div>
);
})}
@ -148,6 +656,175 @@ export default function Integrations() {
</div>
))
)}
{activeEditor && (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeEditor();
}
}}
>
<div className="w-full max-w-lg bg-gray-900 border border-gray-800 rounded-xl shadow-xl">
<div className="px-5 py-4 border-b border-gray-800 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">
Configure {activeEditor.name}
</h3>
<p className="text-xs text-gray-400 mt-0.5">
{activeEditor.configured
? 'Enter only fields you want to update.'
: 'Enter required fields to configure this integration.'}
</p>
</div>
<button
onClick={closeEditor}
disabled={saving}
className="text-gray-400 hover:text-white transition-colors disabled:opacity-50"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-5 space-y-4">
{activeEditor.activates_default_provider && (
<div className="rounded-lg border border-blue-800 bg-blue-950/30 p-3 text-xs text-blue-200">
Saving here updates credentials and switches your default AI provider to{' '}
<strong>{activeEditor.name}</strong>. For advanced provider settings, use{' '}
<Link to="/config" className="underline underline-offset-2 hover:text-blue-100">
Configuration
</Link>
.
</div>
)}
{activeEditor.fields.map((field) => (
(() => {
const selectOptions = modelOptionsForField(activeEditor.id, field);
const isSelectField = selectOptions.length > 0;
const isSecretField = field.input_type === 'secret';
const maskedSecretValue = isSecretField
? (field.masked_value || (field.has_value ? '••••••••' : undefined))
: undefined;
const activeEditorIsDefaultProvider =
activeEditor.activates_default_provider &&
activeEditor.id === activeAiIntegrationId;
const currentModelValue =
field.current_value?.trim() ||
(activeEditorIsDefaultProvider ? runtimeStatus?.model?.trim() || '' : '');
const keepCurrentLabel = currentModelValue
? `Keep current model (${currentModelValue})`
: 'Keep current model';
return (
<div key={field.key}>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-1.5">
<span>{field.label}</span>
{field.required && <span className="text-red-400">*</span>}
{field.has_value && (
<span className="text-[11px] text-green-400 bg-green-900/30 border border-green-800 px-1.5 py-0.5 rounded">
Configured
</span>
)}
</label>
{isSelectField ? (
<div className="space-y-2">
<select
value={fieldValues[field.key] ?? (field.has_value ? SELECT_KEEP : '')}
onChange={(e) => updateField(field.key, e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{field.has_value ? (
<option value={SELECT_KEEP}>{keepCurrentLabel}</option>
) : (
<option value="" disabled>
Select a recommended model
</option>
)}
{selectOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
<option value={SELECT_CUSTOM}>Custom model...</option>
{field.has_value && <option value={SELECT_CLEAR}>Clear current model</option>}
</select>
{fieldValues[field.key] === SELECT_CUSTOM && (
<input
type="text"
value={customFieldValues[field.key] ?? ''}
onChange={(e) => updateCustomField(field.key, e.target.value)}
placeholder={customModelFormatHint(activeEditor.id)}
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
)}
<p className="text-[11px] text-gray-500">
Pick a recommended model or choose Custom model. {customModelFormatHint(activeEditor.id)}.
</p>
</div>
) : (
<div className="space-y-2">
{maskedSecretValue && (
<p className="text-[11px] text-gray-500">
Current value: <span className="font-mono text-gray-300">{maskedSecretValue}</span>
</p>
)}
<input
type={isSecretField ? 'password' : 'text'}
value={fieldValues[field.key] ?? ''}
onChange={(e) => updateField(field.key, e.target.value)}
placeholder={
field.required
? field.has_value
? 'Enter a new value to replace current'
: 'Enter value'
: field.has_value
? 'Type new value, or leave empty to keep current'
: 'Optional'
}
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
)}
</div>
);
})()
))}
{saveError && (
<div className="rounded-lg bg-red-900/30 border border-red-700 p-3 text-sm text-red-300">
{saveError}
</div>
)}
</div>
<div className="px-5 py-4 border-t border-gray-800 flex items-center justify-end gap-2">
<button
onClick={closeEditor}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={saveCredentials}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
>
{saving
? 'Saving...'
: activeEditor.activates_default_provider
? 'Save & Activate'
: 'Save Keys'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -133,7 +133,7 @@ export default function Logs() {
: entries.filter((e) => typeFilters.has(e.event.type));
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
<div className="flex min-h-[28rem] flex-col h-[calc(100dvh-8.5rem)]">
{/* Toolbar */}
<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">

1
web/src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View File

@ -49,6 +49,34 @@ export interface Integration {
status: 'Available' | 'Active' | 'ComingSoon';
}
export interface IntegrationCredentialsField {
key: string;
label: string;
required: boolean;
has_value: boolean;
input_type: 'secret' | 'text' | 'select';
options: string[];
current_value?: string;
masked_value?: string;
}
export interface IntegrationSettingsEntry {
id: string;
name: string;
description: string;
category: string;
status: Integration['status'];
configured: boolean;
activates_default_provider: boolean;
fields: IntegrationCredentialsField[];
}
export interface IntegrationSettingsPayload {
revision: string;
active_default_provider_integration_id?: string;
integrations: IntegrationSettingsEntry[];
}
export interface DiagResult {
severity: 'ok' | 'warn' | 'error';
category: string;
@ -65,6 +93,14 @@ export interface MemoryEntry {
score: number | null;
}
export interface PairedDevice {
id: string;
token_fingerprint: string;
created_at: string | null;
last_seen_at: string | null;
paired_by: string | null;
}
export interface CostSummary {
session_cost_usd: number;
daily_cost_usd: number;
@ -95,11 +131,16 @@ export interface SSEEvent {
}
export interface WsMessage {
type: 'message' | 'chunk' | 'tool_call' | 'tool_result' | 'done' | 'error';
type: 'message' | 'chunk' | 'tool_call' | 'tool_result' | 'done' | 'error' | 'history';
content?: string;
full_response?: string;
name?: string;
args?: any;
output?: string;
message?: string;
session_id?: string;
messages?: Array<{
role: 'user' | 'assistant';
content: string;
}>;
}

View File

@ -21,6 +21,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"types": ["vite/client", "vitest/globals"],
/* Aliases */
"baseUrl": ".",

View File

@ -16,12 +16,16 @@ export default defineConfig({
},
server: {
proxy: {
"/pair": {
target: "http://localhost:42617",
changeOrigin: true,
},
"/api": {
target: "http://localhost:5555",
target: "http://localhost:42617",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:5555",
target: "ws://localhost:42617",
ws: true,
},
},

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

@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['e2e/**'],
css: true,
globals: true,
},
});