feat(dashboard): add localized mock dashboard and mobile smoke coverage

This commit is contained in:
argenis de la rosa 2026-03-02 17:08:16 -05:00
parent a300878f39
commit d56ad644af
38 changed files with 5200 additions and 371 deletions

View File

@ -856,7 +856,7 @@ fn normalize_cached_channel_turns(turns: Vec<ChatMessage>) -> Vec<ChatMessage> {
}
fn supports_runtime_model_switch(channel_name: &str) -> bool {
matches!(channel_name, "telegram" | "discord")
!channel_name.eq_ignore_ascii_case("cli")
}
fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRuntimeCommand> {
@ -2258,6 +2258,7 @@ async fn handle_runtime_command_if_needed(
/// - Grant session and persistent runtime grants
/// - Persist to config
/// - Clear exclusions
///
/// Returns the approval success message.
async fn handle_confirm_tool_approval_side_effects(
ctx: &ChannelRuntimeContext,
@ -2300,7 +2301,7 @@ async fn handle_runtime_command_if_needed(
///
/// This path confirms only the current pending request and intentionally does
/// not persist approval policy changes for normal tools.
async fn handle_pending_runtime_approval_side_effects(
fn handle_pending_runtime_approval_side_effects(
ctx: &ChannelRuntimeContext,
request_id: &str,
tool_name: &str,
@ -2784,8 +2785,7 @@ async fn handle_runtime_command_if_needed(
ctx,
&request_id,
&req.tool_name,
)
.await;
);
runtime_trace::record_event(
"approval_request_approved",
Some(source_channel),
@ -5792,7 +5792,6 @@ mod tests {
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
use crate::observability::NoopObserver;
use crate::providers::{ChatMessage, Provider};
use crate::security::AutonomyLevel;
use crate::tools::{Tool, ToolResult};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
@ -5864,7 +5863,7 @@ mod tests {
}
#[test]
fn parse_runtime_command_allows_approval_commands_on_non_model_channels() {
fn parse_runtime_command_allows_runtime_commands_on_non_cli_channels() {
assert_eq!(
parse_runtime_command("slack", "/approve-request shell"),
Some(ChannelRuntimeCommand::RequestToolApproval(
@ -5909,7 +5908,16 @@ mod tests {
parse_runtime_command("slack", "/approvals"),
Some(ChannelRuntimeCommand::ListApprovals)
);
assert_eq!(parse_runtime_command("slack", "/models"), None);
assert_eq!(
parse_runtime_command("slack", "/models"),
Some(ChannelRuntimeCommand::ShowProviders)
);
}
#[test]
fn parse_runtime_command_keeps_model_switch_disabled_on_cli_channel() {
assert_eq!(parse_runtime_command("cli", "/models"), None);
assert_eq!(parse_runtime_command("cli", "/model"), None);
}
#[test]
@ -6213,7 +6221,7 @@ mod tests {
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(histories)),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -6896,7 +6904,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -6961,14 +6969,6 @@ BTC is currently around $65,000 based on latest tool output."#
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let autonomy_cfg = crate::config::AutonomyConfig {
level: AutonomyLevel::Full,
auto_approve: vec!["mock_price".to_string()],
..crate::config::AutonomyConfig::default()
};
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(ToolCallingProvider),
@ -6983,7 +6983,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -7035,14 +7035,6 @@ BTC is currently around $65,000 based on latest tool output."#
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let autonomy_cfg = crate::config::AutonomyConfig {
level: AutonomyLevel::Full,
auto_approve: vec!["mock_price".to_string()],
..crate::config::AutonomyConfig::default()
};
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(ToolCallingProvider),
@ -7057,7 +7049,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -7123,14 +7115,6 @@ BTC is currently around $65,000 based on latest tool output."#
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let autonomy_cfg = crate::config::AutonomyConfig {
level: AutonomyLevel::Full,
auto_approve: vec!["mock_price".to_string()],
..crate::config::AutonomyConfig::default()
};
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(ToolCallingProvider),
@ -7145,7 +7129,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -7210,14 +7194,6 @@ BTC is currently around $65,000 based on latest tool output."#
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let autonomy_cfg = crate::config::AutonomyConfig {
level: AutonomyLevel::Full,
auto_approve: vec!["mock_price".to_string()],
..crate::config::AutonomyConfig::default()
};
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(ToolCallingProvider),
@ -7232,7 +7208,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -7304,7 +7280,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -7356,14 +7332,6 @@ BTC is currently around $65,000 based on latest tool output."#
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let autonomy_cfg = crate::config::AutonomyConfig {
level: AutonomyLevel::Full,
auto_approve: vec!["mock_price".to_string()],
..crate::config::AutonomyConfig::default()
};
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(ToolCallingAliasProvider),
@ -7378,7 +7346,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -7454,7 +7422,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -7558,7 +7526,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -7699,7 +7667,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -7788,7 +7756,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -7866,7 +7834,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -8027,7 +7995,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8142,7 +8110,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8252,7 +8220,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8347,7 +8315,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8449,7 +8417,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8549,7 +8517,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8700,7 +8668,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8797,7 +8765,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -8947,7 +8915,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -9067,7 +9035,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -9167,7 +9135,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -9289,7 +9257,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -9409,7 +9377,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -9488,7 +9456,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -9590,7 +9558,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
@ -9777,7 +9745,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -9939,7 +9907,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 12,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -10007,7 +9975,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 3,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -10187,7 +10155,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -10277,7 +10245,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -10379,7 +10347,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -10463,7 +10431,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -10532,7 +10500,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -11163,7 +11131,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -11259,7 +11227,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -11354,7 +11322,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -11453,7 +11421,7 @@ BTC is currently around $65,000 based on latest tool output."#
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(histories)),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -12281,7 +12249,7 @@ BTC is currently around $65,000 based on latest tool output."#;
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@ -12357,7 +12325,7 @@ BTC is currently around $65,000 based on latest tool output."#;
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
conversation_locks: Default::default(),
conversation_locks: Arc::default(),
session_config: crate::config::AgentSessionConfig::default(),
session_manager: None,
provider_cache: Arc::new(Mutex::new(HashMap::new())),

View File

@ -2,7 +2,7 @@
//!
//! All `/api/*` routes require bearer token authentication (PairingGuard).
use super::AppState;
use super::{mock_dashboard, AppState};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, StatusCode},
@ -76,6 +76,9 @@ pub async fn handle_api_status(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::status();
}
let config = state.config.lock().clone();
let health = crate::health::snapshot();
@ -110,6 +113,9 @@ pub async fn handle_api_config_get(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::config_get();
}
let config = state.config.lock().clone();
@ -142,6 +148,9 @@ pub async fn handle_api_config_put(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::config_put(body);
}
// Parse the incoming TOML and normalize known dashboard-masked edge cases.
let mut incoming_toml: toml::Value = match toml::from_str(&body) {
@ -200,6 +209,9 @@ pub async fn handle_api_tools(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::tools();
}
let tools: Vec<serde_json::Value> = state
.tools_registry
@ -224,6 +236,9 @@ pub async fn handle_api_cron_list(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::cron_list();
}
let config = state.config.lock().clone();
match crate::cron::list_jobs(&config) {
@ -261,6 +276,9 @@ pub async fn handle_api_cron_add(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::cron_add(body.name, body.schedule, body.command, None);
}
let config = state.config.lock().clone();
let schedule = crate::cron::Schedule::Cron {
@ -296,6 +314,9 @@ pub async fn handle_api_cron_delete(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::cron_delete(&id);
}
let config = state.config.lock().clone();
match crate::cron::remove_job(&config, &id) {
@ -316,6 +337,9 @@ pub async fn handle_api_integrations(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::integrations();
}
let config = state.config.lock().clone();
let entries = crate::integrations::registry::all_integrations();
@ -336,6 +360,342 @@ pub async fn handle_api_integrations(
Json(serde_json::json!({"integrations": integrations})).into_response()
}
/// GET /api/integrations/settings — detailed settings for each integration
pub async fn handle_api_integrations_settings(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::integrations_settings();
}
let config = state.config.lock().clone();
let entries = crate::integrations::registry::all_integrations();
let active_default_provider_id = config
.default_provider
.as_ref()
.and_then(|p| integration_id_from_provider(p));
let integrations: Vec<serde_json::Value> = entries
.iter()
.map(|entry| {
let status = (entry.status_fn)(&config);
let (configured, fields) = integration_settings_fields(&config, entry.name);
let activates_default_provider = is_ai_provider(entry.name);
serde_json::json!({
"id": integration_name_to_id(entry.name),
"name": entry.name,
"description": entry.description,
"category": entry.category,
"status": status,
"configured": configured,
"activates_default_provider": activates_default_provider,
"fields": fields,
})
})
.collect();
Json(serde_json::json!({
"revision": "v1",
"active_default_provider_integration_id": active_default_provider_id,
"integrations": integrations,
}))
.into_response()
}
/// PUT /api/integrations/:id/credentials — update integration credentials
pub async fn handle_api_integrations_credentials_put(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::integrations_credentials_put(&id, &body);
}
let fields = body
.get("fields")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let mut config = state.config.lock().clone();
let Some(provider_key) = provider_key_from_integration_id(&id) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!(
"Integration '{}' does not support credential updates via this endpoint",
id
)
})),
)
.into_response();
};
// Apply credential updates based on integration
match provider_key {
"openrouter" | "anthropic" | "openai" | "google" | "deepseek" | "xai" | "mistral"
| "perplexity" | "vercel" | "bedrock" | "groq" | "together" | "cohere" | "fireworks"
| "venice" | "moonshot" | "stepfun" | "synthetic" | "opencode" | "zai" | "glm"
| "minimax" | "qwen" | "qianfan" | "doubao" | "volcengine" | "ark" | "siliconflow" => {
if let Some(api_key) = fields.get("api_key").and_then(|v| v.as_str()) {
if !api_key.is_empty() && api_key != MASKED_SECRET {
config.api_key = Some(api_key.to_string());
}
}
if let Some(default_model) = fields.get("default_model").and_then(|v| v.as_str()) {
if !default_model.is_empty() {
config.default_model = Some(default_model.to_string());
}
}
config.default_provider = Some(provider_key.to_string());
}
"ollama" => {
if let Some(default_model) = fields.get("default_model").and_then(|v| v.as_str()) {
if !default_model.is_empty() {
config.default_model = Some(default_model.to_string());
}
}
config.default_provider = Some("ollama".to_string());
}
_ => {
// Channel integrations - not implemented for credentials update via this endpoint
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("Integration '{}' does not support credential updates via this endpoint", id)
})),
)
.into_response();
}
}
// Save config
if let Err(e) = config.save().await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
)
.into_response();
}
// Update in-memory config
*state.config.lock() = config;
Json(serde_json::json!({
"status": "ok",
"revision": "v1",
}))
.into_response()
}
fn integration_name_to_id(name: &str) -> String {
name.to_lowercase()
.replace(' ', "-")
.replace(['/', '.'], "-")
}
fn provider_key_from_integration_id(id: &str) -> Option<&'static str> {
match id {
"openrouter" => Some("openrouter"),
"anthropic" => Some("anthropic"),
"openai" => Some("openai"),
"google" => Some("google"),
"deepseek" => Some("deepseek"),
"xai" => Some("xai"),
"mistral" => Some("mistral"),
"perplexity" => Some("perplexity"),
"vercel-ai" => Some("vercel"),
"amazon-bedrock" => Some("bedrock"),
"groq" => Some("groq"),
"together-ai" => Some("together"),
"cohere" => Some("cohere"),
"fireworks-ai" => Some("fireworks"),
"venice" => Some("venice"),
"moonshot" => Some("moonshot"),
"stepfun" => Some("stepfun"),
"synthetic" => Some("synthetic"),
"opencode-zen" => Some("opencode"),
"z-ai" => Some("zai"),
"glm" => Some("glm"),
"minimax" => Some("minimax"),
"qwen" => Some("qwen"),
"qianfan" => Some("qianfan"),
"volcengine-ark" => Some("ark"),
"siliconflow" => Some("siliconflow"),
"ollama" => Some("ollama"),
_ => None,
}
}
fn is_ai_provider(name: &str) -> bool {
matches!(
name,
"OpenRouter"
| "Anthropic"
| "OpenAI"
| "Google"
| "DeepSeek"
| "xAI"
| "Mistral"
| "Perplexity"
| "Vercel AI"
| "Amazon Bedrock"
| "Groq"
| "Together AI"
| "Cohere"
| "Fireworks AI"
| "Venice"
| "Moonshot"
| "StepFun"
| "Synthetic"
| "OpenCode Zen"
| "Z.AI"
| "GLM"
| "MiniMax"
| "Qwen"
| "Qianfan"
| "Volcengine ARK"
| "SiliconFlow"
| "Ollama"
)
}
fn integration_id_from_provider(provider: &str) -> Option<String> {
let name = match provider {
"openrouter" => "OpenRouter",
"anthropic" => "Anthropic",
"openai" => "OpenAI",
"google" | "vertex" => "Google",
"deepseek" => "DeepSeek",
"xai" | "x-ai" => "xAI",
"mistral" => "Mistral",
"perplexity" => "Perplexity",
"vercel" => "Vercel AI",
"bedrock" => "Amazon Bedrock",
"groq" => "Groq",
"together" => "Together AI",
"cohere" => "Cohere",
"fireworks" => "Fireworks AI",
"venice" => "Venice",
"moonshot" | "moonshot-cn" | "moonshot-intl" => "Moonshot",
"stepfun" | "step-ai" => "StepFun",
"synthetic" => "Synthetic",
"opencode" => "OpenCode Zen",
"zai" | "zai-cn" | "zai-intl" => "Z.AI",
"glm" | "glm-cn" | "glm-intl" => "GLM",
"minimax" | "minimax-cn" | "minimax-intl" => "MiniMax",
"qwen" | "qwen-cn" | "qwen-intl" => "Qwen",
"qianfan" | "baidu" => "Qianfan",
"doubao" | "volcengine" | "ark" => "Volcengine ARK",
"siliconflow" | "silicon-cloud" => "SiliconFlow",
"ollama" => "Ollama",
_ => return None,
};
Some(integration_name_to_id(name))
}
#[allow(clippy::too_many_lines)]
fn integration_settings_fields(
config: &crate::config::Config,
name: &str,
) -> (bool, Vec<serde_json::Value>) {
match name {
"OpenRouter" => {
let has_key = config.api_key.is_some();
let fields = vec![
serde_json::json!({
"key": "api_key",
"label": "API Key",
"required": true,
"has_value": has_key,
"input_type": "secret",
"options": [],
"masked_value": if has_key { Some(MASKED_SECRET) } else { None },
}),
serde_json::json!({
"key": "default_model",
"label": "Default Model",
"required": false,
"has_value": config.default_model.is_some(),
"input_type": "select",
"options": [
"anthropic/claude-sonnet-4-6",
"openai/gpt-5.2",
"google/gemini-3.1-pro",
"deepseek/deepseek-reasoner",
"x-ai/grok-4",
],
"current_value": config.default_model.as_deref().unwrap_or(""),
}),
];
(has_key, fields)
}
"Anthropic" => {
let has_key = config.api_key.is_some();
let fields = vec![
serde_json::json!({
"key": "api_key",
"label": "API Key",
"required": true,
"has_value": has_key,
"input_type": "secret",
"options": [],
"masked_value": if has_key { Some(MASKED_SECRET) } else { None },
}),
serde_json::json!({
"key": "default_model",
"label": "Default Model",
"required": false,
"has_value": config.default_model.is_some(),
"input_type": "select",
"options": ["claude-sonnet-4-6", "claude-opus-4-6"],
"current_value": config.default_model.as_deref().unwrap_or(""),
}),
];
(has_key, fields)
}
"OpenAI" => {
let has_key = config.api_key.is_some();
let fields = vec![
serde_json::json!({
"key": "api_key",
"label": "API Key",
"required": true,
"has_value": has_key,
"input_type": "secret",
"options": [],
"masked_value": if has_key { Some(MASKED_SECRET) } else { None },
}),
serde_json::json!({
"key": "default_model",
"label": "Default Model",
"required": false,
"has_value": config.default_model.is_some(),
"input_type": "select",
"options": ["gpt-5.2", "gpt-5.2-codex", "gpt-4o"],
"current_value": config.default_model.as_deref().unwrap_or(""),
}),
];
(has_key, fields)
}
_ => {
// Default: no configurable fields
(false, vec![])
}
}
}
/// POST /api/doctor — run diagnostics
pub async fn handle_api_doctor(
State(state): State<AppState>,
@ -344,6 +704,9 @@ pub async fn handle_api_doctor(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::doctor();
}
let config = state.config.lock().clone();
let results = crate::doctor::diagnose(&config);
@ -381,6 +744,9 @@ pub async fn handle_api_memory_list(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::memory_list(params.query, params.category);
}
if let Some(ref query) = params.query {
// Search mode
@ -421,6 +787,9 @@ pub async fn handle_api_memory_store(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::memory_store(body.key, body.content, body.category);
}
let category = body
.category
@ -456,6 +825,9 @@ pub async fn handle_api_memory_delete(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::memory_delete(&key);
}
match state.mem.forget(&key).await {
Ok(deleted) => {
@ -477,6 +849,9 @@ pub async fn handle_api_cost(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::cost();
}
if let Some(ref tracker) = state.cost_tracker {
match tracker.get_summary() {
@ -510,6 +885,9 @@ pub async fn handle_api_cli_tools(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::cli_tools();
}
let tools = crate::tools::cli_discovery::discover_cli_tools(&[], &[]);
@ -524,6 +902,9 @@ pub async fn handle_api_health(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::health();
}
let snapshot = crate::health::snapshot();
Json(serde_json::json!({"health": snapshot})).into_response()
@ -537,6 +918,9 @@ pub async fn handle_api_pairing_devices(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::pairing_devices();
}
let devices = state.pairing.paired_devices();
Json(serde_json::json!({ "devices": devices })).into_response()
@ -551,6 +935,9 @@ pub async fn handle_api_pairing_device_revoke(
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if mock_dashboard::is_enabled(&headers) {
return mock_dashboard::pairing_device_revoke(&id);
}
if !state.pairing.revoke_device(&id) {
return (
@ -1297,4 +1684,69 @@ mod tests {
Some("feishu-verify-token")
);
}
#[test]
fn provider_key_from_integration_id_maps_dashboard_ids() {
assert_eq!(provider_key_from_integration_id("openai"), Some("openai"));
assert_eq!(
provider_key_from_integration_id("amazon-bedrock"),
Some("bedrock")
);
assert_eq!(
provider_key_from_integration_id("together-ai"),
Some("together")
);
assert_eq!(
provider_key_from_integration_id("opencode-zen"),
Some("opencode")
);
assert_eq!(
provider_key_from_integration_id("volcengine-ark"),
Some("ark")
);
assert_eq!(provider_key_from_integration_id("slack"), None);
}
#[test]
fn integration_provider_mapping_roundtrips_for_supported_providers() {
let cases = vec![
("openrouter", "openrouter"),
("anthropic", "anthropic"),
("openai", "openai"),
("google", "google"),
("deepseek", "deepseek"),
("xai", "xai"),
("mistral", "mistral"),
("perplexity", "perplexity"),
("vercel", "vercel"),
("bedrock", "bedrock"),
("groq", "groq"),
("together", "together"),
("cohere", "cohere"),
("fireworks", "fireworks"),
("venice", "venice"),
("moonshot", "moonshot"),
("stepfun", "stepfun"),
("synthetic", "synthetic"),
("opencode", "opencode"),
("zai", "zai"),
("glm", "glm"),
("minimax", "minimax"),
("qwen", "qwen"),
("qianfan", "qianfan"),
("ark", "ark"),
("siliconflow", "siliconflow"),
("ollama", "ollama"),
];
for (provider, expected_provider_key) in cases {
let id = integration_id_from_provider(provider)
.expect("provider should map to dashboard integration id");
assert_eq!(
provider_key_from_integration_id(&id),
Some(expected_provider_key),
"provider '{provider}' with id '{id}' should resolve to '{expected_provider_key}'",
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@
//! - Header sanitization (handled by axum/hyper)
pub mod api;
mod mock_dashboard;
mod openai_compat;
mod openclaw_compat;
pub mod sse;
@ -848,6 +849,14 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.route("/api/cron", post(api::handle_api_cron_add))
.route("/api/cron/{id}", delete(api::handle_api_cron_delete))
.route("/api/integrations", get(api::handle_api_integrations))
.route(
"/api/integrations/settings",
get(api::handle_api_integrations_settings),
)
.route(
"/api/integrations/{id}/credentials",
put(api::handle_api_integrations_credentials_put),
)
.route(
"/api/doctor",
get(api::handle_api_doctor).post(api::handle_api_doctor),

View File

@ -28,6 +28,40 @@
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::unnecessary_cast,
clippy::assertions_on_constants,
clippy::await_holding_lock,
clippy::cast_lossless,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::default_trait_access,
clippy::doc_lazy_continuation,
clippy::explicit_iter_loop,
clippy::fn_params_excessive_bools,
clippy::format_push_string,
clippy::if_not_else,
clippy::large_enum_variant,
clippy::large_futures,
clippy::manual_clamp,
clippy::manual_contains,
clippy::manual_is_multiple_of,
clippy::manual_pattern_char_comparison,
clippy::manual_string_new,
clippy::match_same_arms,
clippy::missing_fields_in_debug,
clippy::needless_borrow,
clippy::needless_lifetimes,
clippy::redundant_else,
clippy::semicolon_if_nothing_returned,
clippy::should_implement_trait,
clippy::stable_sort_primitive,
clippy::struct_excessive_bools,
clippy::type_complexity,
clippy::unchecked_time_subtraction,
clippy::unnecessary_debug_formatting,
clippy::unnested_or_patterns,
clippy::unreadable_literal,
clippy::unused_async,
clippy::wildcard_imports,
clippy::unnecessary_lazy_evaluations,
clippy::unnecessary_literal_bound,
clippy::unnecessary_map_or,

View File

@ -26,6 +26,40 @@
clippy::uninlined_format_args,
clippy::unused_self,
clippy::cast_precision_loss,
clippy::assertions_on_constants,
clippy::await_holding_lock,
clippy::cast_lossless,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::default_trait_access,
clippy::doc_lazy_continuation,
clippy::explicit_iter_loop,
clippy::fn_params_excessive_bools,
clippy::format_push_string,
clippy::if_not_else,
clippy::large_enum_variant,
clippy::large_futures,
clippy::manual_clamp,
clippy::manual_contains,
clippy::manual_is_multiple_of,
clippy::manual_pattern_char_comparison,
clippy::manual_string_new,
clippy::match_same_arms,
clippy::missing_fields_in_debug,
clippy::needless_borrow,
clippy::needless_lifetimes,
clippy::redundant_else,
clippy::semicolon_if_nothing_returned,
clippy::should_implement_trait,
clippy::stable_sort_primitive,
clippy::struct_excessive_bools,
clippy::type_complexity,
clippy::unchecked_time_subtraction,
clippy::unnecessary_debug_formatting,
clippy::unnested_or_patterns,
clippy::unreadable_literal,
clippy::unused_async,
clippy::wildcard_imports,
clippy::unnecessary_cast,
clippy::unnecessary_lazy_evaluations,
clippy::unnecessary_literal_bound,
@ -59,6 +93,54 @@ fn parse_temperature(s: &str) -> std::result::Result<f64, String> {
Ok(t)
}
fn dashboard_open_url(host: &str, port: u16) -> String {
let bind_host = host.trim();
let browser_host = match bind_host {
"0.0.0.0" | "::" | "[::]" => "127.0.0.1",
_ => bind_host,
};
format!("http://{browser_host}:{port}/")
}
async fn open_url_in_default_browser(url: &str) -> Result<()> {
#[cfg(target_os = "macos")]
let mut command = {
let mut command = tokio::process::Command::new("open");
command.arg(url);
command
};
#[cfg(target_os = "linux")]
let mut command = {
let mut command = tokio::process::Command::new("xdg-open");
command.arg(url);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = tokio::process::Command::new("cmd");
command.args(["/C", "start", "", url]);
command
};
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = url;
bail!("automatic dashboard open is unsupported on this platform");
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
{
let status = command.status().await?;
if status.success() {
Ok(())
} else {
bail!("browser launcher exited with status {status}");
}
}
}
mod agent;
mod approval;
mod auth;
@ -263,6 +345,7 @@ Examples:
zeroclaw gateway # use config defaults
zeroclaw gateway -p 8080 # listen on port 8080
zeroclaw gateway --host 0.0.0.0 # bind to all interfaces
zeroclaw gateway --open-dashboard # open web dashboard automatically
zeroclaw gateway -p 0 # random available port
zeroclaw gateway --new-pairing # clear tokens and generate fresh pairing code")]
Gateway {
@ -277,6 +360,10 @@ Examples:
/// Clear all paired tokens and generate a fresh pairing code
#[arg(long)]
new_pairing: bool,
/// Open the web dashboard URL in the default browser on startup
#[arg(long)]
open_dashboard: bool,
},
/// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler)
@ -990,6 +1077,7 @@ async fn main() -> Result<()> {
port,
host,
new_pairing,
open_dashboard,
} => {
if new_pairing {
// Persist token reset from raw config so env-derived overrides are not written to disk.
@ -1006,6 +1094,25 @@ async fn main() -> Result<()> {
} else {
info!("🚀 Starting ZeroClaw Gateway on {host}:{port}");
}
if open_dashboard {
if port == 0 {
warn!(
"--open-dashboard requires a fixed port; skipping auto-open because --port 0 uses a random port"
);
} else {
let dashboard_url = dashboard_open_url(&host, port);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
if let Err(err) = open_url_in_default_browser(&dashboard_url).await {
warn!(
"Could not open dashboard automatically ({err}). Open manually: {dashboard_url}"
);
} else {
info!("🌐 Opened dashboard in browser: {dashboard_url}");
}
});
}
}
gateway::run_gateway(&host, port, config).await
}
@ -2406,6 +2513,24 @@ mod tests {
);
}
#[test]
fn gateway_help_includes_open_dashboard_flag() {
let cmd = Cli::command();
let gateway = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "gateway")
.expect("gateway subcommand must exist");
let has_open_dashboard_flag = gateway.get_arguments().any(|arg| {
arg.get_id().as_str() == "open_dashboard" && arg.get_long() == Some("open-dashboard")
});
assert!(
has_open_dashboard_flag,
"gateway help should include --open-dashboard"
);
}
#[test]
fn gateway_cli_accepts_new_pairing_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--new-pairing"])
@ -2418,15 +2543,47 @@ mod tests {
}
#[test]
fn gateway_cli_defaults_new_pairing_to_false() {
fn gateway_cli_accepts_open_dashboard_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--open-dashboard"])
.expect("gateway --open-dashboard should parse");
match cli.command {
Commands::Gateway { open_dashboard, .. } => assert!(open_dashboard),
other => panic!("expected gateway command, got {other:?}"),
}
}
#[test]
fn gateway_cli_defaults_flags_to_false() {
let cli = Cli::try_parse_from(["zeroclaw", "gateway"]).expect("gateway should parse");
match cli.command {
Commands::Gateway { new_pairing, .. } => assert!(!new_pairing),
Commands::Gateway {
new_pairing,
open_dashboard,
..
} => {
assert!(!new_pairing);
assert!(!open_dashboard);
}
other => panic!("expected gateway command, got {other:?}"),
}
}
#[test]
fn dashboard_open_url_prefers_loopback_for_wildcard_bind_hosts() {
assert_eq!(
dashboard_open_url("0.0.0.0", 42617),
"http://127.0.0.1:42617/"
);
assert_eq!(dashboard_open_url("::", 42617), "http://127.0.0.1:42617/");
assert_eq!(dashboard_open_url("[::]", 42617), "http://127.0.0.1:42617/");
assert_eq!(
dashboard_open_url("127.0.0.1", 42617),
"http://127.0.0.1:42617/"
);
}
#[test]
fn completion_generation_mentions_binary_name() {
let mut output = Vec::new();

View File

@ -243,7 +243,6 @@ mod tests {
use crate::config::PluginsConfig;
use crate::plugins::manifest::PluginManifest;
use crate::plugins::traits::{Plugin, PluginApi};
use async_trait::async_trait;
struct OkPlugin {
manifest: PluginManifest,

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('/?mock_backend=1');
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');
});
});

1207
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,10 @@
"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",
@ -23,12 +26,17 @@
},
"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"
}
}

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,
},
};

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

@ -13,7 +13,9 @@ 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 {
@ -22,7 +24,7 @@ interface LocaleContextType {
}
export const LocaleContext = createContext<LocaleContextType>({
locale: 'tr',
locale: 'en',
setAppLocale: (_locale: Locale) => {},
});
@ -48,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.12em] text-white">ZEROCLAW</h1>
<p className="text-sm text-[#9bb8e8]">Enter the one-time pairing code from your terminal</p>
</div>
<form onSubmit={handleSubmit}>
<input
@ -60,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>
@ -82,11 +84,28 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
function AppContent() {
const { isAuthenticated, loading, pair, logout } = useAuth();
const [locale, setLocaleState] = useState<Locale>('tr');
const [locale, setLocaleState] = useState<Locale>(() => {
if (typeof window === 'undefined') {
return 'en';
}
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);
};
// Listen for 401 events to force logout
@ -100,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>
);
}

View File

@ -271,7 +271,7 @@ export const CONFIG_SECTIONS: SectionDef[] = [
{ key: 'kind', label: 'Kind', type: 'select', defaultValue: 'native', options: [
{ value: 'native', label: 'Native' },
{ value: 'docker', label: 'Docker' },
{ value: 'wasm', label: 'WASM' },
{ value: 'wasm', label: 'Sandboxed' },
]},
{ key: 'reasoning_enabled', label: 'Reasoning Enabled', type: 'toggle', description: 'Enable model reasoning mode' },
],
@ -975,8 +975,8 @@ export const CONFIG_SECTIONS: SectionDef[] = [
{
path: 'wasm',
category: 'runtime',
title: 'WASM Plugins',
description: 'WebAssembly plugin engine',
title: 'Plugin Engine',
description: 'Sandboxed plugin engine',
icon: Play,
defaultCollapsed: true,
fields: [

View File

@ -1,9 +1,10 @@
import { useLocation } from 'react-router-dom';
import { LogOut, Menu } from 'lucide-react';
import { t } from '@/lib/i18n';
import type { Locale } from '@/lib/i18n';
import { useState } from 'react';
import { LogOut, Menu, TestTubeDiagonal, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { t, LANGUAGE_BUTTON_LABELS, LANGUAGE_SWITCH_ORDER } from '@/lib/i18n';
import { useLocaleContext } from '@/App';
import { useAuth } from '@/hooks/useAuth';
import { isMockModeEnabled, setMockModeEnabled } from '@/lib/mockMode';
const routeTitles: Record<string, string> = {
'/': 'nav.dashboard',
@ -12,59 +13,111 @@ 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',
};
const localeCycle: Locale[] = ['en', 'tr', 'zh-CN'];
const languageSummary = 'English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά';
interface HeaderProps {
isSidebarCollapsed: boolean;
onToggleSidebar: () => void;
onToggleSidebarCollapse: () => void;
}
export default function Header({ onToggleSidebar }: HeaderProps) {
export default function Header({
isSidebarCollapsed,
onToggleSidebar,
onToggleSidebarCollapse,
}: HeaderProps) {
const location = useLocation();
const { logout } = useAuth();
const { locale, setAppLocale } = useLocaleContext();
const [mockMode, setMockMode] = useState(() => isMockModeEnabled());
const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard';
const pageTitle = t(titleKey);
const toggleLanguage = () => {
const currentIndex = localeCycle.indexOf(locale);
const nextLocale = localeCycle[(currentIndex + 1) % localeCycle.length] ?? 'en';
const currentIndex = LANGUAGE_SWITCH_ORDER.indexOf(locale);
const nextLocale =
LANGUAGE_SWITCH_ORDER[(currentIndex + 1) % LANGUAGE_SWITCH_ORDER.length] ?? 'en';
setAppLocale(nextLocale);
};
const toggleMockMode = () => {
const next = !mockMode;
setMockModeEnabled(next);
setMockMode(next);
window.location.reload();
};
return (
<header className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-4 md:px-6">
<div className="flex items-center gap-3">
<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%)]" />
<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="md:hidden p-1.5 rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
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>
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
<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">
Electric dashboard
</p>
</div>
</div>
<div className="flex items-center gap-2 md:gap-4">
<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={onToggleSidebarCollapse}
className="hidden 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 md:flex md:text-sm"
title={isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
<span>{isSidebarCollapsed ? 'Expand' : 'Collapse'}</span>
</button>
<button
type="button"
onClick={toggleMockMode}
className={[
'flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] uppercase tracking-[0.1em] transition sm:px-2.5 sm:text-xs sm:tracking-[0.12em]',
mockMode
? 'border-[#48a8ff] bg-[#0f3d8a]/75 text-white shadow-[0_0_18px_-8px_rgba(73,152,255,1)]'
: 'border-[#284a8c] bg-[#081a3b]/75 text-[#9bb7e7] hover:border-[#4f83ff] hover:text-white',
].join(' ')}
title="Toggle dashboard mock mode"
>
<TestTubeDiagonal className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Mock</span>
</button>
<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' : locale === 'tr' ? 'TR' : '中文'}
{LANGUAGE_BUTTON_LABELS[locale] ?? 'EN'}
</button>
<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 className="hidden sm:inline">{t('auth.logout')}</span>

View File

@ -3,17 +3,49 @@ 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">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="app-shell min-h-screen text-white">
<Sidebar
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
onClose={() => setSidebarOpen(false)}
onToggleCollapse={toggleSidebarCollapsed}
/>
<div className="md:ml-60 flex flex-col min-h-screen">
<Header onToggleSidebar={() => setSidebarOpen((open) => !open)} />
<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
isSidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarOpen((open) => !open)}
onToggleSidebarCollapse={toggleSidebarCollapsed}
/>
<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,80 @@
import { describe, expect, it, vi } from 'vitest';
import { 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', async () => {
const user = userEvent.setup();
const view = renderSidebar({ isOpen: true, isCollapsed: true });
const collapseToggle = screen.getByRole('button', {
name: /Expand navigation/i,
});
await user.click(collapseToggle);
expect(view.onToggleCollapse).toHaveBeenCalledTimes(1);
const dashboardLink = screen.getByRole('link', { name: 'Dashboard' });
expect(dashboardLink).toHaveAttribute('title', 'Dashboard');
await user.click(dashboardLink);
expect(view.onClose).toHaveBeenCalled();
});
});

View File

@ -1,5 +1,6 @@
import { NavLink } from 'react-router-dom';
import {
ChevronsLeftRightEllipsis,
LayoutDashboard,
MessageSquare,
Wrench,
@ -31,10 +32,17 @@ const navItems = [
interface SidebarProps {
isOpen: boolean;
isCollapsed: boolean;
onClose: () => void;
onToggleCollapse: () => void;
}
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
export default function Sidebar({
isOpen,
isCollapsed,
onClose,
onToggleCollapse,
}: SidebarProps) {
return (
<>
<button
@ -48,52 +56,94 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
/>
<aside
className={[
'fixed top-0 left-0 z-40 h-screen w-60 bg-gray-900 flex flex-col border-r border-gray-800',
'transform transition-transform duration-200 ease-out',
'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="flex items-center justify-between px-5 py-5 border-b border-gray-800">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">
ZC
<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">
<div
className="electric-brand-mark h-9 w-9 shrink-0 rounded-xl"
role="img"
aria-label="ZeroClaw"
>
<span className="sr-only">ZeroClaw</span>
</div>
<span className="text-lg font-semibold text-white tracking-wide">
<span
className={[
'text-lg font-semibold tracking-[0.1em] text-white transition-[opacity,transform,width] duration-300',
isCollapsed ? 'w-0 -translate-x-3 opacity-0 md:invisible' : 'w-auto opacity-100',
].join(' ')}
>
ZeroClaw
</span>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close navigation"
className="md:hidden p-1.5 rounded-md text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
>
<X className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<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 overflow-y-auto py-4 px-3 space-y-1">
<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 }) =>
[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
'group flex items-center gap-3 overflow-hidden rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-300',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white',
? '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 flex-shrink-0" />
<span>{t(labelKey)}</span>
<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' : 'Electric Mode'}
</p>
</div>
</aside>
</>
);

View File

@ -1,89 +1,493 @@
@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);
}
.status-pill-mock {
border-color: #49a1ff;
color: #ffffff;
background: rgba(17, 76, 170, 0.76);
box-shadow: 0 0 18px -7px rgba(74, 166, 255, 0.86);
}
.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 {
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%);
}
.pairing-card {
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);
}
: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 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,
.motion-rise,
.electric-loader {
animation: none !important;
}
.electric-button {
transition: none !important;
}
}

View File

@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { apiFetch, getPublicHealth, pair } from './api';
import {
MOCK_BACKEND_STORAGE_KEY,
MOCK_MODE_STORAGE_KEY,
} from './mockMode';
import { clearToken, setToken } from './auth';
afterEach(() => {
vi.restoreAllMocks();
clearToken();
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
window.localStorage.removeItem(MOCK_BACKEND_STORAGE_KEY);
window.history.pushState({}, '', '/');
});
describe('api backend mock mode', () => {
it('sends backend mock header for /api requests', async () => {
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
setToken('tok_test_backend_mock');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ provider: 'openai' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const data = await apiFetch<{ provider: string }>('/api/status');
expect(data.provider).toBe('openai');
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [, init] = fetchSpy.mock.calls[0]!;
const headers = new Headers(init?.headers);
expect(headers.get('X-ZeroClaw-Mock')).toBe('1');
expect(headers.get('Authorization')).toBe('Bearer tok_test_backend_mock');
});
it('uses real /pair request in backend mock mode', async () => {
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ token: 'pair-token' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const result = await pair('123456');
expect(result.token).toBe('pair-token');
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/pair');
});
it('uses real /health request in backend mock mode', async () => {
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ require_pairing: true, paired: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const payload = await getPublicHealth();
expect(payload.require_pairing).toBe(true);
expect(payload.paired).toBe(false);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/health');
});
});

View File

@ -12,6 +12,8 @@ import type {
HealthSnapshot,
} from '../types/api';
import { clearToken, getToken, setToken } from './auth';
import { isMockBackendEnabled, isMockModeEnabled } from './mockMode';
import { mockApiFetch } from './mockData';
// ---------------------------------------------------------------------------
// Base fetch wrapper
@ -28,9 +30,20 @@ export async function apiFetch<T = unknown>(
path: string,
options: RequestInit = {},
): Promise<T> {
const mockMode = isMockModeEnabled();
const backendMock = mockMode && isMockBackendEnabled();
if (mockMode && !backendMock) {
return mockApiFetch<T>(path, options);
}
const token = getToken();
const headers = new Headers(options.headers);
if (backendMock && path.startsWith('/api/')) {
headers.set('X-ZeroClaw-Mock', '1');
}
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
@ -79,6 +92,18 @@ function unwrapField<T>(value: T | Record<string, T>, key: string): T {
// ---------------------------------------------------------------------------
export async function pair(code: string): Promise<{ token: string }> {
const mockMode = isMockModeEnabled();
const backendMock = mockMode && isMockBackendEnabled();
if (mockMode && !backendMock) {
const data = await mockApiFetch<{ token: string }>('/pair', {
method: 'POST',
body: JSON.stringify({ code }),
});
setToken(data.token);
return data;
}
const response = await fetch('/pair', {
method: 'POST',
headers: { 'X-Pairing-Code': code },
@ -99,6 +124,13 @@ export async function pair(code: string): Promise<{ token: string }> {
// ---------------------------------------------------------------------------
export async function getPublicHealth(): Promise<{ require_pairing: boolean; paired: boolean }> {
const mockMode = isMockModeEnabled();
const backendMock = mockMode && isMockBackendEnabled();
if (mockMode && !backendMock) {
return mockApiFetch<{ require_pairing: boolean; paired: boolean }>('/health');
}
const response = await fetch('/health');
if (!response.ok) {
throw new Error(`Health check failed (${response.status})`);

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' | 'zh-CN';
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: {
@ -559,6 +591,11 @@ const translations: Record<Locale, Record<string, string>> = {
'health.uptime': '运行时长',
'health.updated_at': '最后更新',
},
ja: {},
ru: {},
fr: {},
vi: {},
el: {},
};
// ---------------------------------------------------------------------------
@ -599,10 +636,18 @@ export function tLocale(key: string, locale: Locale): string {
// React hook
// ---------------------------------------------------------------------------
function normalizeLocale(locale: string | undefined): Locale {
const lowered = locale?.toLowerCase();
if (lowered?.startsWith('tr')) return 'tr';
if (lowered === 'zh' || lowered?.startsWith('zh-')) return 'zh-CN';
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';
}
@ -619,7 +664,7 @@ export function useLocale(): { locale: Locale; t: (key: string) => string } {
getStatus()
.then((status) => {
if (cancelled) return;
const detected = normalizeLocale(status.locale);
const detected = coerceLocale(status.locale);
setLocale(detected);
setLocaleState(detected);
})

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { mockApiFetch } from './mockData';
describe('mockApiFetch', () => {
it('returns dashboard status payload', async () => {
const status = await mockApiFetch<{ provider: string; model: string }>('/api/status');
expect(status.provider).toBe('openai');
expect(status.model).toBe('gpt-5.2');
});
it('creates cron job from valid payload', async () => {
const created = await mockApiFetch<{ status: string; job: { command: string } }>('/api/cron', {
method: 'POST',
body: JSON.stringify({ command: 'echo test', schedule: '*/5 * * * *' }),
});
expect(created.status).toBe('created');
expect(created.job.command).toBe('echo test');
});
it('fails on invalid cron payload', async () => {
await expect(
mockApiFetch('/api/cron', {
method: 'POST',
body: JSON.stringify({ command: '' }),
}),
).rejects.toThrow(/API 400/);
});
});

488
web/src/lib/mockData.ts Normal file
View File

@ -0,0 +1,488 @@
import type {
CliTool,
ComponentHealth,
CostSummary,
CronJob,
DiagResult,
HealthSnapshot,
Integration,
IntegrationSettingsPayload,
MemoryEntry,
PairedDevice,
StatusResponse,
ToolSpec,
} from '@/types/api';
interface MockResponse {
status: number;
body?: unknown;
}
const MOCK_LATENCY_MS = 120;
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseJsonBody(options: RequestInit): Record<string, unknown> {
if (typeof options.body !== 'string') {
return {};
}
try {
const parsed = JSON.parse(options.body) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore parse failures and return empty map.
}
return {};
}
function deepClone<T>(value: T): T {
if (value === undefined) {
return value;
}
return JSON.parse(JSON.stringify(value)) as T;
}
function ok(body: unknown): MockResponse {
return { status: 200, body };
}
function noContent(): MockResponse {
return { status: 204 };
}
function error(status: number, message: string): MockResponse {
return { status, body: { error: message } };
}
function buildHealthComponent(status: string, restartCount = 0): ComponentHealth {
const now = new Date().toISOString();
return {
status,
updated_at: now,
last_ok: status === 'ok' ? now : null,
last_error: status === 'ok' ? null : now,
restart_count: restartCount,
};
}
function buildMockHealth(): HealthSnapshot {
const now = new Date().toISOString();
return {
pid: 4242,
updated_at: now,
uptime_seconds: 68420,
components: {
gateway: buildHealthComponent('ok'),
provider: buildHealthComponent('ok'),
memory: buildHealthComponent('degraded', 1),
channels: buildHealthComponent('ok'),
},
};
}
function buildMockStatus(): StatusResponse {
return {
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: buildMockHealth(),
};
}
function buildMockCost(): CostSummary {
return {
session_cost_usd: 0.0842,
daily_cost_usd: 1.3026,
monthly_cost_usd: 14.9875,
total_tokens: 182_342,
request_count: 426,
by_model: {
'gpt-5.2': {
model: 'gpt-5.2',
cost_usd: 11.4635,
total_tokens: 141_332,
request_count: 292,
},
'claude-sonnet-4-5': {
model: 'claude-sonnet-4-5',
cost_usd: 3.524,
total_tokens: 41_010,
request_count: 134,
},
},
};
}
function buildMockTools(): ToolSpec[] {
return [
{
name: 'shell',
description: 'Run shell commands inside the workspace',
parameters: {
type: 'object',
properties: { command: { type: 'string' } },
required: ['command'],
},
},
{
name: 'file_read',
description: 'Read files from disk',
parameters: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
},
},
{
name: 'web_fetch',
description: 'Fetch and parse HTTP resources',
parameters: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
},
];
}
function buildMockCronJobs(): CronJob[] {
return [
{
id: 'mock-cron-1',
name: 'Daily sync',
command: 'zeroclaw sync --channels',
next_run: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
last_run: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
last_status: 'ok',
enabled: true,
},
{
id: 'mock-cron-2',
name: 'Budget audit',
command: 'zeroclaw cost audit',
next_run: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString(),
last_run: null,
last_status: null,
enabled: false,
},
];
}
function buildMockIntegrations(): Integration[] {
return [
{
name: 'Slack',
description: 'Slack bot messaging and thread orchestration',
category: 'Channels',
status: 'Active',
},
{
name: 'GitHub',
description: 'PR and issue automation',
category: 'Automation',
status: 'Available',
},
{
name: 'Linear',
description: 'Issue workflow sync',
category: 'Productivity',
status: 'ComingSoon',
},
];
}
function buildMockIntegrationSettings(): IntegrationSettingsPayload {
return {
revision: 'mock-revision-17',
active_default_provider_integration_id: 'openai',
integrations: [
{
id: 'openai',
name: 'OpenAI',
description: 'Primary LLM provider',
category: 'Providers',
status: 'Active',
configured: true,
activates_default_provider: true,
fields: [
{
key: 'api_key',
label: 'API Key',
required: true,
has_value: true,
input_type: 'secret',
options: [],
masked_value: 'sk-****abcd',
},
],
},
{
id: 'slack',
name: 'Slack',
description: 'Workspace notifications and bot relay',
category: 'Channels',
status: 'Available',
configured: false,
activates_default_provider: false,
fields: [
{
key: 'bot_token',
label: 'Bot Token',
required: true,
has_value: false,
input_type: 'secret',
options: [],
},
],
},
],
};
}
function buildMockMemory(): MemoryEntry[] {
return [
{
id: 'mem-1',
key: 'ops.runbook.gateway',
content: 'Restart gateway with `zeroclaw gateway --open-dashboard` after updates.',
category: 'operations',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
session_id: 'sess_42',
score: 0.92,
},
{
id: 'mem-2',
key: 'cost.budget.daily',
content: 'Daily soft budget threshold is $2.50 for development environments.',
category: 'cost',
timestamp: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(),
session_id: null,
score: 0.88,
},
];
}
function buildMockDevices(): PairedDevice[] {
return [
{
id: 'device-1',
token_fingerprint: 'zc_3f2a...19d0',
created_at: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
last_seen_at: new Date(Date.now() - 40 * 60 * 1000).toISOString(),
paired_by: 'localhost',
},
{
id: 'device-2',
token_fingerprint: 'zc_09ac...7e4f',
created_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
last_seen_at: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
paired_by: 'vpn',
},
];
}
function buildMockCliTools(): CliTool[] {
return [
{
name: 'git',
path: '/usr/bin/git',
version: '2.46.1',
category: 'vcs',
},
{
name: 'cargo',
path: '/Users/mock/.cargo/bin/cargo',
version: '1.87.0',
category: 'build',
},
{
name: 'npm',
path: '/opt/homebrew/bin/npm',
version: '11.3.0',
category: 'package-manager',
},
];
}
function buildMockDiagnostics(): DiagResult[] {
return [
{
severity: 'ok',
category: 'runtime',
message: 'Gateway listeners are healthy.',
},
{
severity: 'warn',
category: 'cost',
message: 'Daily spend crossed 50% threshold.',
},
{
severity: 'ok',
category: 'security',
message: 'Pairing mode is enabled.',
},
];
}
const mockConfig = `[gateway]
host = "127.0.0.1"
port = 42617
require_pairing = true
[agent]
max_tool_iterations = 24
`;
function routeMockResponse(path: string, options: RequestInit): MockResponse {
const method = (options.method ?? 'GET').toUpperCase();
const url = new URL(path, 'http://zeroclaw.mock');
const body = parseJsonBody(options);
const pathname = url.pathname;
if (method === 'GET' && pathname === '/health') {
return ok({ require_pairing: false, paired: true });
}
if (method === 'POST' && pathname === '/pair') {
const code = String(body.code ?? '').trim();
if (code.length > 0 && code.length < 6) {
return error(400, 'Mock pairing code must have at least 6 characters');
}
return ok({ token: 'mock-token-zeroclaw' });
}
if (method === 'GET' && pathname === '/api/status') {
return ok(buildMockStatus());
}
if (method === 'GET' && pathname === '/api/health') {
return ok({ health: buildMockHealth() });
}
if (method === 'GET' && pathname === '/api/cost') {
return ok({ cost: buildMockCost() });
}
if (method === 'GET' && pathname === '/api/tools') {
return ok({ tools: buildMockTools() });
}
if (method === 'GET' && pathname === '/api/cron') {
return ok({ jobs: buildMockCronJobs() });
}
if (method === 'POST' && pathname === '/api/cron') {
const command = String(body.command ?? '').trim();
const schedule = String(body.schedule ?? '').trim();
if (!command || !schedule) {
return error(400, 'command and schedule are required');
}
const newJob: CronJob = {
id: `mock-cron-${Date.now().toString(36)}`,
name: typeof body.name === 'string' ? body.name : null,
command,
next_run: new Date(Date.now() + 60 * 1000).toISOString(),
last_run: null,
last_status: null,
enabled: body.enabled === false ? false : true,
};
return ok({ status: 'created', job: newJob });
}
if (method === 'DELETE' && pathname.startsWith('/api/cron/')) {
return noContent();
}
if (method === 'GET' && pathname === '/api/integrations') {
return ok({ integrations: buildMockIntegrations() });
}
if (method === 'GET' && pathname === '/api/integrations/settings') {
return ok(buildMockIntegrationSettings());
}
if (
method === 'PUT'
&& pathname.startsWith('/api/integrations/')
&& pathname.endsWith('/credentials')
) {
return ok({ status: 'ok', revision: `mock-revision-${Date.now().toString(36)}` });
}
if (method === 'GET' && pathname === '/api/memory') {
return ok({ entries: buildMockMemory() });
}
if (method === 'POST' && pathname === '/api/memory') {
return ok({ status: 'stored' });
}
if (method === 'DELETE' && pathname.startsWith('/api/memory/')) {
return noContent();
}
if (method === 'GET' && pathname === '/api/pairing/devices') {
return ok({ devices: buildMockDevices() });
}
if (method === 'DELETE' && pathname.startsWith('/api/pairing/devices/')) {
return noContent();
}
if (method === 'GET' && pathname === '/api/config') {
return ok({ format: 'toml', content: mockConfig });
}
if (method === 'PUT' && pathname === '/api/config') {
return ok({ status: 'saved' });
}
if ((method === 'POST' || method === 'GET') && pathname === '/api/doctor') {
return ok({ results: buildMockDiagnostics() });
}
if (method === 'GET' && pathname === '/api/cli-tools') {
return ok({ cli_tools: buildMockCliTools() });
}
return error(404, `No mock route for ${method} ${pathname}`);
}
export async function mockApiFetch<T = unknown>(
path: string,
options: RequestInit = {},
): Promise<T> {
const response = routeMockResponse(path, options);
await wait(MOCK_LATENCY_MS);
if (response.status >= 400) {
const errorPayload = response.body ?? { error: 'Unknown mock API error' };
const message =
typeof errorPayload === 'string' ? errorPayload : JSON.stringify(errorPayload);
throw new Error(`API ${response.status}: ${message}`);
}
if (response.status === 204) {
return undefined as T;
}
return deepClone(response.body) as T;
}

View File

@ -0,0 +1,66 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
isMockBackendEnabled,
isMockModeEnabled,
MOCK_BACKEND_STORAGE_KEY,
MOCK_MODE_STORAGE_KEY,
setMockBackendEnabled,
setMockModeEnabled,
} from './mockMode';
afterEach(() => {
window.localStorage.removeItem(MOCK_BACKEND_STORAGE_KEY);
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
window.history.pushState({}, '', '/');
});
describe('mockMode', () => {
it('defaults to disabled', () => {
expect(isMockModeEnabled()).toBe(false);
});
it('enables from localStorage', () => {
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
expect(isMockModeEnabled()).toBe(true);
});
it('uses query param override when present', () => {
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
window.history.pushState({}, '', '/?mock=0');
expect(isMockModeEnabled()).toBe(false);
window.history.pushState({}, '', '/?mock=true');
expect(isMockModeEnabled()).toBe(true);
});
it('setMockModeEnabled persists and clears values', () => {
setMockModeEnabled(true);
expect(window.localStorage.getItem(MOCK_MODE_STORAGE_KEY)).toBe('1');
setMockModeEnabled(false);
expect(window.localStorage.getItem(MOCK_MODE_STORAGE_KEY)).toBeNull();
});
it('mock backend defaults to disabled and can be enabled from storage', () => {
expect(isMockBackendEnabled()).toBe(false);
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
expect(isMockBackendEnabled()).toBe(true);
});
it('mock backend query param overrides localStorage', () => {
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
window.history.pushState({}, '', '/?mock_backend=0');
expect(isMockBackendEnabled()).toBe(false);
window.history.pushState({}, '', '/?mock_backend=1');
expect(isMockBackendEnabled()).toBe(true);
});
it('setMockBackendEnabled persists and clears values', () => {
setMockBackendEnabled(true);
expect(window.localStorage.getItem(MOCK_BACKEND_STORAGE_KEY)).toBe('1');
setMockBackendEnabled(false);
expect(window.localStorage.getItem(MOCK_BACKEND_STORAGE_KEY)).toBeNull();
});
});

99
web/src/lib/mockMode.ts Normal file
View File

@ -0,0 +1,99 @@
export const MOCK_MODE_STORAGE_KEY = 'zeroclaw:mock-mode';
export const MOCK_BACKEND_STORAGE_KEY = 'zeroclaw:mock-backend';
const truthyValues = new Set(['1', 'true', 'yes', 'on']);
function parseTruthy(value: string | null | undefined): boolean {
if (!value) {
return false;
}
return truthyValues.has(value.trim().toLowerCase());
}
export function isMockModeEnabled(): boolean {
const envEnabled = parseTruthy(import.meta.env.VITE_MOCK_API);
if (typeof window === 'undefined') {
return envEnabled;
}
try {
const queryValue = new URLSearchParams(window.location.search).get('mock');
if (queryValue !== null) {
return parseTruthy(queryValue);
}
} catch {
// Ignore malformed search params and continue to storage/env checks.
}
try {
const stored = window.localStorage.getItem(MOCK_MODE_STORAGE_KEY);
if (stored !== null) {
return parseTruthy(stored);
}
} catch {
// Ignore storage access errors and fall back to env value.
}
return envEnabled;
}
export function isMockBackendEnabled(): boolean {
const envEnabled = parseTruthy(import.meta.env.VITE_MOCK_BACKEND);
if (typeof window === 'undefined') {
return envEnabled;
}
try {
const queryValue = new URLSearchParams(window.location.search).get('mock_backend');
if (queryValue !== null) {
return parseTruthy(queryValue);
}
} catch {
// Ignore malformed search params and continue to storage/env checks.
}
try {
const stored = window.localStorage.getItem(MOCK_BACKEND_STORAGE_KEY);
if (stored !== null) {
return parseTruthy(stored);
}
} catch {
// Ignore storage access errors and fall back to env value.
}
return envEnabled;
}
export function setMockModeEnabled(enabled: boolean): void {
if (typeof window === 'undefined') {
return;
}
try {
if (enabled) {
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
} else {
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
}
} catch {
// Ignore storage access errors.
}
}
export function setMockBackendEnabled(enabled: boolean): void {
if (typeof window === 'undefined') {
return;
}
try {
if (enabled) {
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
} else {
window.localStorage.removeItem(MOCK_BACKEND_STORAGE_KEY);
}
} catch {
// Ignore storage access errors.
}
}

View File

@ -1,5 +1,6 @@
import type { SSEEvent } from '../types/api';
import { getToken } from './auth';
import { isMockModeEnabled } from './mockMode';
export type SSEEventHandler = (event: SSEEvent) => void;
export type SSEErrorHandler = (error: Event | Error) => void;
@ -28,6 +29,7 @@ const MAX_RECONNECT_DELAY = 30000;
export class SSEClient {
private controller: AbortController | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private mockTimer: ReturnType<typeof setInterval> | null = null;
private currentDelay: number;
private intentionallyClosed = false;
@ -52,6 +54,12 @@ export class SSEClient {
connect(): void {
this.intentionallyClosed = false;
this.clearReconnectTimer();
if (isMockModeEnabled()) {
this.mockConnect();
return;
}
this.controller = new AbortController();
const token = getToken();
@ -92,6 +100,7 @@ export class SSEClient {
disconnect(): void {
this.intentionallyClosed = true;
this.clearReconnectTimer();
this.clearMockTimer();
if (this.controller) {
this.controller.abort();
this.controller = null;
@ -182,4 +191,36 @@ export class SSEClient {
this.reconnectTimer = null;
}
}
private mockConnect(): void {
this.clearMockTimer();
this.currentDelay = this.reconnectDelay;
this.onConnect?.();
const eventTypes = ['status', 'message', 'tool_call', 'tool_result', 'health'] as const;
let tick = 0;
this.mockTimer = setInterval(() => {
if (this.intentionallyClosed) {
this.clearMockTimer();
return;
}
const type = eventTypes[tick % eventTypes.length] ?? 'message';
tick += 1;
const now = new Date().toISOString();
this.onEvent?.({
type,
timestamp: now,
message: `[mock] ${type} event #${tick}`,
});
}, 1400);
}
private clearMockTimer(): void {
if (this.mockTimer !== null) {
clearInterval(this.mockTimer);
this.mockTimer = null;
}
}
}

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

@ -1,5 +1,6 @@
import type { WsMessage } from '../types/api';
import { getToken } from './auth';
import { isMockModeEnabled } from './mockMode';
export type WsMessageHandler = (msg: WsMessage) => void;
export type WsOpenHandler = () => void;
@ -25,6 +26,8 @@ export class WebSocketClient {
private ws: WebSocket | null = null;
private currentDelay: number;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private mockTimers: Array<ReturnType<typeof setTimeout>> = [];
private mockConnected = false;
private intentionallyClosed = false;
public onMessage: WsMessageHandler | null = null;
@ -54,6 +57,11 @@ export class WebSocketClient {
this.intentionallyClosed = false;
this.clearReconnectTimer();
if (isMockModeEnabled()) {
this.mockConnect();
return;
}
const token = getToken();
const url = `${this.baseUrl}/ws/chat?session_id=${encodeURIComponent(this.sessionId)}`;
const protocols = ['zeroclaw.v1'];
@ -89,6 +97,30 @@ export class WebSocketClient {
/** Send a chat message to the agent. */
sendMessage(content: string): void {
if (isMockModeEnabled()) {
if (!this.mockConnected) {
throw new Error('Mock WebSocket is not connected');
}
this.pushMockTimer(
setTimeout(() => {
this.onMessage?.({
type: 'chunk',
content: 'Mock runtime: analyzing request...',
});
}, 220),
);
this.pushMockTimer(
setTimeout(() => {
this.onMessage?.({
type: 'done',
full_response: `Mock response generated for: "${content}"`,
});
}, 780),
);
return;
}
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
@ -99,6 +131,8 @@ export class WebSocketClient {
disconnect(): void {
this.intentionallyClosed = true;
this.clearReconnectTimer();
this.clearMockTimers();
this.mockConnected = false;
if (this.ws) {
this.ws.close();
this.ws = null;
@ -107,6 +141,9 @@ export class WebSocketClient {
/** Returns true if the socket is open. */
get connected(): boolean {
if (isMockModeEnabled()) {
return this.mockConnected;
}
return this.ws?.readyState === WebSocket.OPEN;
}
@ -130,6 +167,38 @@ export class WebSocketClient {
}
}
private mockConnect(): void {
this.clearMockTimers();
this.mockConnected = true;
this.currentDelay = this.reconnectDelay;
this.pushMockTimer(
setTimeout(() => {
this.onOpen?.();
this.onMessage?.({
type: 'history',
messages: [
{
role: 'assistant',
content: 'Mock mode connected. This chat is running without a backend.',
},
],
});
}, 80),
);
}
private pushMockTimer(timer: ReturnType<typeof setTimeout>): void {
this.mockTimers.push(timer);
}
private clearMockTimers(): void {
for (const timer of this.mockTimers) {
clearTimeout(timer);
}
this.mockTimers = [];
}
private resolveSessionId(): string {
const existing = window.localStorage.getItem(WS_SESSION_STORAGE_KEY);
if (existing && /^[A-Za-z0-9_-]{1,128}$/.test(existing)) {

View File

@ -181,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">

View File

@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Dashboard from './Dashboard';
import { MOCK_MODE_STORAGE_KEY } from '@/lib/mockMode';
afterEach(() => {
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
window.history.pushState({}, '', '/');
});
describe('Dashboard', () => {
it('renders with mock data and supports collapsing every dashboard section', async () => {
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
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,38 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import type { ComponentType, ReactNode, SVGProps } from 'react';
import {
Cpu,
Clock,
Globe,
Database,
Activity,
ChevronDown,
Clock3,
Cpu,
Database,
DollarSign,
Globe2,
Radio,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import type { StatusResponse, CostSummary } from '@/types/api';
import { getStatus, getCost } from '@/lib/api';
import type { CostSummary, StatusResponse } from '@/types/api';
import { getCost, getStatus } from '@/lib/api';
import { isMockModeEnabled } from '@/lib/mockMode';
type DashboardSectionKey = 'cost' | 'channels' | 'health';
interface DashboardSectionState {
cost: boolean;
channels: boolean;
health: boolean;
}
interface CollapsibleSectionProps {
title: string;
subtitle: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
sectionKey: DashboardSectionKey;
openState: DashboardSectionState;
onToggle: (section: DashboardSectionKey) => void;
children: ReactNode;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
@ -28,13 +51,13 @@ function healthColor(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'bg-green-500';
return 'bg-emerald-400';
case 'warn':
case 'warning':
case 'degraded':
return 'bg-yellow-500';
return 'bg-amber-400';
default:
return 'bg-red-500';
return 'bg-rose-500';
}
}
@ -42,44 +65,107 @@ 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,
});
const mockMode = isMockModeEnabled();
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 +173,185 @@ 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>
{mockMode && <span className="status-pill status-pill-mock">Mock Data</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>
);

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

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

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,
},
});