From 9098ffd38f6c68b228ba68cdabe1a9107be749c8 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:24:45 +0800 Subject: [PATCH] refactor(webui): improve visual consistency (#5249) --- .../websocket/tests/test_websocket_channel.py | 3 +- nanobot/config/schema.py | 22 +- nanobot/config/timezone.py | 19 + nanobot/webui/settings_api.py | 6 +- pyproject.toml | 1 + tests/config/test_timezone.py | 123 +++++ tests/webui/test_settings_api.py | 20 + webui/src/App.tsx | 11 +- webui/src/components/DeleteConfirm.tsx | 4 +- webui/src/components/FileReferenceChip.tsx | 3 +- webui/src/components/MessageBubble.tsx | 17 +- webui/src/components/RenameChatDialog.tsx | 2 +- webui/src/components/SessionSearchDialog.tsx | 3 +- .../src/components/settings/SettingsView.tsx | 458 ++---------------- .../settings/SkillsCatalogSettings.tsx | 129 ++--- .../components/settings/SkillsMarketplace.tsx | 73 ++- .../components/settings/TokenUsageHeatmap.tsx | 2 +- .../settings/channels/ChannelIdentity.tsx | 6 +- .../channels/ChannelInstancesPanel.tsx | 14 +- .../settings/channels/ChannelSetupPanel.tsx | 22 +- .../settings/channels/ChannelSetupParts.tsx | 21 +- .../thread/AssistantSelectionAction.tsx | 8 +- webui/src/components/thread/PromptRail.tsx | 4 +- .../src/components/thread/ThreadComposer.tsx | 5 +- webui/src/components/ui/alert-dialog.tsx | 11 +- webui/src/components/ui/dialog.tsx | 9 +- webui/src/components/ui/floating-surface.ts | 11 +- webui/src/components/ui/segmented-control.tsx | 62 +++ webui/src/components/ui/sheet.tsx | 12 +- webui/src/components/ui/tooltip.tsx | 4 +- webui/src/tests/app-layout.test.tsx | 93 +++- webui/src/tests/message-bubble.test.tsx | 32 ++ webui/src/tests/settings-view.test.tsx | 14 +- 33 files changed, 578 insertions(+), 646 deletions(-) create mode 100644 nanobot/config/timezone.py create mode 100644 tests/config/test_timezone.py create mode 100644 webui/src/components/ui/segmented-control.tsx diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 2389e3793..ee89bf906 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -2545,6 +2545,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( ) config.tools.web.search.provider = "brave" config.tools.web.search.api_key = "brave-secret" + expected_timezone = config.agents.defaults.timezone save_config(config, config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr( @@ -2585,7 +2586,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert body["agent"]["provider"] == "openai" assert body["agent"]["model_preset"] == "default" assert body["agent"]["max_tokens"] == 8192 - assert body["agent"]["timezone"] == "UTC" + assert body["agent"]["timezone"] == expected_timezone assert "bot_name" not in body["agent"] assert "bot_icon" not in body["agent"] assert body["agent"]["tool_hint_max_length"] == 40 diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 83f6aa46e..e997fea30 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -2,11 +2,12 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from nanobot.config.timezone import detect_system_timezone from nanobot.config_base import Base from nanobot.cron.types import CronSchedule @@ -140,7 +141,8 @@ class AgentDefaults(Base): serialization_alias="toolHintMaxLength", ) # Max characters for tool hint display (e.g. "$ cd …/project && npm test") reasoning_effort: str | None = None # low / medium / high / xhigh / max / adaptive / none — LLM thinking effort; None preserves the provider default - timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" + timezone: str = "UTC" # Effective IANA timezone, e.g. "Asia/Shanghai" + timezone_mode: Literal["auto", "manual"] = "auto" bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...") bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit unified_session: bool = False # Share one session across all channels (single-user multi-device) @@ -164,6 +166,22 @@ class AgentDefaults(Base): ) # Consolidation target ratio (0.5 = 50% of budget retained after compression) dream: DreamConfig = Field(default_factory=DreamConfig) + @model_validator(mode="before") + @classmethod + def resolve_timezone(cls, value: object) -> object: + """Detect new defaults server-side while preserving configured timezones.""" + if not isinstance(value, dict): + return value + + data = dict(cast(dict[str, object], value)) + timezone_mode = data.get("timezoneMode", data.get("timezone_mode")) + if timezone_mode is None: + timezone_mode = "manual" if "timezone" in data else "auto" + data["timezoneMode"] = timezone_mode + if timezone_mode == "auto": + data["timezone"] = detect_system_timezone() + return data + @field_validator("timezone") @classmethod def validate_timezone(cls, value: str) -> str: diff --git a/nanobot/config/timezone.py b/nanobot/config/timezone.py new file mode 100644 index 000000000..dac2f9b40 --- /dev/null +++ b/nanobot/config/timezone.py @@ -0,0 +1,19 @@ +"""Backend timezone detection for automatic agent defaults.""" + +from zoneinfo import ZoneInfo + +from tzlocal import get_localzone_name + +_UTC_ALIASES = frozenset( + {"Etc/GMT", "Etc/UTC", "GMT", "GMT0", "Greenwich", "UCT", "Universal", "Zulu"} +) + + +def detect_system_timezone() -> str: + """Return the host's IANA timezone, falling back safely to UTC.""" + try: + timezone = get_localzone_name() + ZoneInfo(timezone) + except Exception: + return "UTC" + return "UTC" if timezone in _UTC_ALIASES else timezone diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 687c3d936..877c35b03 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -1399,10 +1399,12 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]: ZoneInfo(timezone) except Exception: raise WebUISettingsError("invalid timezone") from None - if defaults.timezone != timezone: + timezone_changed = defaults.timezone != timezone + if timezone_changed or defaults.timezone_mode != "manual": defaults.timezone = timezone + defaults.timezone_mode = "manual" changed = True - restart_required = True + restart_required = timezone_changed tool_hint_max_length = _query_first_alias( query, diff --git a/pyproject.toml b/pyproject.toml index a4056afdf..eb7c10187 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "watchfiles>=1.1.1,<2.0.0", "packaging>=24.0", "tzdata>=2025.2", + "tzlocal>=5.3.1,<6.0.0", "defusedxml>=0.7.1,<1.0.0", "pypdf>=5.0.0,<6.0.0", "python-docx>=1.1.0,<2.0.0", diff --git a/tests/config/test_timezone.py b/tests/config/test_timezone.py new file mode 100644 index 000000000..084d9b9a8 --- /dev/null +++ b/tests/config/test_timezone.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json + +from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config +from nanobot.config.timezone import detect_system_timezone + + +def test_new_config_detects_backend_timezone(monkeypatch) -> None: + monkeypatch.setattr( + "nanobot.config.timezone.get_localzone_name", + lambda: "Asia/Shanghai", + ) + + config = Config() + + assert config.agents.defaults.timezone == "Asia/Shanghai" + assert config.agents.defaults.timezone_mode == "auto" + + +def test_legacy_config_preserves_explicit_timezone(tmp_path, monkeypatch) -> None: + monkeypatch.setattr( + "nanobot.config.timezone.get_localzone_name", + lambda: "Asia/Shanghai", + ) + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"agents": {"defaults": {"timezone": "America/New_York"}}}), + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.agents.defaults.timezone == "America/New_York" + assert config.agents.defaults.timezone_mode == "manual" + + +def test_auto_timezone_is_detected_by_backend_on_load(tmp_path, monkeypatch) -> None: + monkeypatch.setattr( + "nanobot.config.timezone.get_localzone_name", + lambda: "Asia/Shanghai", + ) + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "agents": { + "defaults": { + "timezone": "UTC", + "timezoneMode": "auto", + } + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.agents.defaults.timezone == "Asia/Shanghai" + assert config.agents.defaults.timezone_mode == "auto" + + +def test_manual_timezone_serializes_explicit_provenance(tmp_path) -> None: + config_path = tmp_path / "config.json" + config = Config.model_validate( + {"agents": {"defaults": {"timezone": "America/New_York"}}} + ) + + save_config(config, config_path) + + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["agents"]["defaults"]["timezone"] == "America/New_York" + assert saved["agents"]["defaults"]["timezoneMode"] == "manual" + + +def test_onboard_refresh_materializes_manual_timezone_mode(tmp_path, monkeypatch) -> None: + config_path = tmp_path / "config.json" + workspace = tmp_path / "workspace" + config_path.write_text( + json.dumps({"agents": {"defaults": {"timezone": "America/New_York"}}}), + encoding="utf-8", + ) + monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path) + monkeypatch.setattr( + "nanobot.cli.commands.get_workspace_path", + lambda _workspace=None: workspace, + ) + monkeypatch.setattr("nanobot.cli.commands._onboard_plugins", lambda _path: None) + + from typer.testing import CliRunner + + from nanobot.cli.commands import app + + result = CliRunner().invoke(app, ["onboard", "--refresh"]) + + assert result.exit_code == 0, result.output + saved = json.loads(config_path.read_text(encoding="utf-8")) + defaults = saved["agents"]["defaults"] + assert defaults["timezone"] == "America/New_York" + assert defaults["timezoneMode"] == "manual" + + +def test_backend_timezone_detection_falls_back_to_utc(monkeypatch) -> None: + def unavailable_timezone() -> str: + raise OSError("timezone unavailable") + + monkeypatch.setattr( + "nanobot.config.timezone.get_localzone_name", + unavailable_timezone, + ) + + assert detect_system_timezone() == "UTC" + + +def test_backend_timezone_detection_normalizes_utc_aliases(monkeypatch) -> None: + monkeypatch.setattr( + "nanobot.config.timezone.get_localzone_name", + lambda: "Etc/UTC", + ) + + assert detect_system_timezone() == "UTC" diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 0ab93558e..7574dce30 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -778,6 +778,26 @@ def test_update_agent_settings_accepts_context_window_options( assert saved.agents.defaults.context_window_tokens == 200000 +def test_update_agent_settings_marks_timezone_as_manual( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "nanobot.config.timezone.get_localzone_name", + lambda: "Asia/Shanghai", + ) + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = update_agent_settings({"timezone": ["Asia/Shanghai"]}) + + assert payload["requires_restart"] is False + saved = load_config(config_path) + assert saved.agents.defaults.timezone == "Asia/Shanghai" + assert saved.agents.defaults.timezone_mode == "manual" + + def test_update_model_configuration_preserves_custom_context_windows( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/App.tsx b/webui/src/App.tsx index de10ca966..9b4d67ada 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -14,6 +14,7 @@ import { channelUiPresentation } from "@/channel-plugins/registry"; import { Sidebar } from "@/components/Sidebar"; import type { SettingsSectionKey } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; +import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useSessions } from "@/hooks/useSessions"; @@ -479,8 +480,8 @@ function PairingCodePopup({ className={cn( "fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]", "w-[min(calc(100vw-2rem),24rem)] rounded-[24px]", - "border border-border/70 bg-popover/95 p-4 text-popover-foreground", - "shadow-[0_24px_70px_rgba(15,23,42,0.20)] backdrop-blur-xl", + floatingSurfaceElevationClassName, + "p-4", "animate-in fade-in-0 slide-in-from-top-2 duration-200", )} > @@ -2128,7 +2129,6 @@ function Shell({ onModelNameChange={onModelNameChange} onSettingsChange={setSettingsSnapshot} skills={skills} - onWorkspaceSettingsChange={refreshWorkspaces} onSectionChange={onSettingsSectionChange} onLogout={onLogout} onRestart={onRestart} @@ -2179,7 +2179,10 @@ function Shell({ {restartToast ? (