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 ? (
{restartToast}
diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index 654dcf298..f7ccb5c2a 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -38,7 +38,7 @@ export function DeleteConfirm({ return ( (!o ? onCancel() : undefined)}>
@@ -89,7 +89,7 @@ export function DeleteConfirm({ {hasAutomations ? t("deleteConfirm.confirmWithAutomations") diff --git a/webui/src/components/FileReferenceChip.tsx b/webui/src/components/FileReferenceChip.tsx index 6298c42ae..ddb129c79 100644 --- a/webui/src/components/FileReferenceChip.tsx +++ b/webui/src/components/FileReferenceChip.tsx @@ -111,9 +111,8 @@ export function FileReferenceChip({ collisionPadding={12} className={cn( "max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]", - "border-border/60 bg-popover/95 px-2.5 py-1.5", + "px-2.5 py-1.5", "break-all font-mono text-[11px] leading-snug text-popover-foreground", - "shadow-lg backdrop-blur", )} > {fullPath} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 373a088cf..336934b98 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -266,7 +266,6 @@ export function MessageBubble({ onForkFromHere, }: MessageBubbleProps) { const { t } = useTranslation(); - const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; const mentionCliApps = useMemo( () => mergeCliMentionApps(cliApps, message.cliApps), [cliApps, message.cliApps], @@ -277,7 +276,7 @@ export function MessageBubble({ ); if (message.kind === "trace") { - return ; + return ; } if (message.role === "user") { @@ -314,12 +313,7 @@ export function MessageBubble({ /> ); return ( -
+
{hasImages ? : null} {!hasImages && hasMedia ? ( @@ -410,7 +404,7 @@ export function MessageBubble({ message.role === "assistant" && (!empty || hasReasoning || media.length > 0); return ( -
+
{hasReasoning ? ( +
- - -
-
- - setQuery(event.target.value)} - {...navigation.inputProps} - placeholder={tx("settings.timezone.search", "Search timezone")} - aria-label={tx("settings.timezone.search", "Search timezone")} - className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0" - /> -
-
- {filteredOptions.length ? ( -
- {filteredOptions.map((option) => { - const selected = option.name === value; - return ( - - {option.name} - - - {option.offset} - - {selected ? : null} - - - ); - })} -
- ) : ( -
- {tx("settings.timezone.empty", "No matching timezones.")} -
- )} -
- - ); -} - function ProviderPicker({ providers, value, @@ -9459,62 +9157,6 @@ function providerVisibilityRank(provider: SettingsPayload["providers"][number]): return 200; } -interface TimezoneOption { - name: string; - offset: string; - searchText: string; -} - -function timezoneOptions(current: string): TimezoneOption[] { - return timezonesWithCurrent(current).map((name) => { - const offset = timezoneOffset(name); - return { - name, - offset, - searchText: `${name} ${name.replace(/_/g, " ")} ${offset}`.toLowerCase(), - }; - }); -} - -function timezonesWithCurrent(current: string): string[] { - const intl = Intl as typeof Intl & { - supportedValuesOf?: (key: "timeZone") => string[]; - }; - let values: string[]; - try { - values = intl.supportedValuesOf?.("timeZone") ?? []; - } catch { - values = []; - } - const deduped = new Set([...FALLBACK_TIMEZONES, ...values, current].filter(Boolean)); - return Array.from(deduped).sort((left, right) => { - if (left === "UTC") return -1; - if (right === "UTC") return 1; - return left.localeCompare(right); - }); -} - -function filterTimezoneOptions(options: TimezoneOption[], query: string): TimezoneOption[] { - const normalized = query.trim().toLowerCase(); - if (!normalized) return options; - return options.filter((option) => option.searchText.includes(normalized)); -} - -function timezoneOffset(timezone: string): string { - try { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - timeZoneName: "shortOffset", - hour: "2-digit", - minute: "2-digit", - }).formatToParts(new Date()); - const value = parts.find((part) => part.type === "timeZoneName")?.value; - return value ? value.replace(/^GMT$/, "UTC").replace(/^GMT/, "UTC") : "UTC"; - } catch { - return "Custom timezone"; - } -} - function optionRowsWithCurrent( options: Array<{ name: string; label: string }>, value: string, @@ -9931,36 +9573,6 @@ function StatusPill({ ); } -function SegmentedControl({ - value, - options, - onChange, -}: { - value: string; - options: Array<{ value: string; label: string }>; - onChange: (value: string) => void; -}) { - return ( -
- {options.map((option) => ( - - ))} -
- ); -} - function NumberInput({ value, min, diff --git a/webui/src/components/settings/SkillsCatalogSettings.tsx b/webui/src/components/settings/SkillsCatalogSettings.tsx index cacc54b84..9f8df11a9 100644 --- a/webui/src/components/settings/SkillsCatalogSettings.tsx +++ b/webui/src/components/settings/SkillsCatalogSettings.tsx @@ -26,6 +26,7 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { SegmentedControl } from "@/components/ui/segmented-control"; import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace"; import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api"; @@ -36,9 +37,6 @@ import { useClient } from "@/providers/ClientProvider"; export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) { const { t } = useTranslation(); - const availableCount = skills.filter( - (skill) => skill.enabled !== false && skill.available, - ).length; const [selectedSkill, setSelectedSkill] = useState(null); const [view, setView] = useState<"installed" | "discover">("installed"); const [installingSkill, setInstallingSkill] = useState(""); @@ -80,51 +78,28 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) { return (
-
-

- {t("settings.skills.description", { - defaultValue: - "Review installed skills or discover new capabilities from the skills.sh catalog.", - })} -

- - {t("settings.skills.caption", { - available: availableCount, - total: skills.length, - defaultValue: "{{available}} available · {{total}} total", - })} - -
- -
- {(["installed", "discover"] as const).map((item) => ( - - ))} -
+ {view === "installed" ? (
-
+
-
- {([ + ( - - ))} -
+ ] as const).map(([value, label, count]) => ({ + value, + label: ( + <> + {label} {count} + + ), + }))} + onChange={setInstalledFilter} + />
{groupedSkills.length ? ( -
+
{groupedSkills.map((group) => ( -
-
+
+

{group.label}

@@ -189,7 +156,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) { {group.skills.length}
-
+
{group.skills.map((skill) => ( onSelect(skill)} className={cn( "group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left", - "transition-[background-color,box-shadow] duration-150", - "hover:bg-muted/70 hover:shadow-[inset_0_0_0_1px_hsl(var(--border)/0.35)]", + "transition-colors duration-150", + "hover:bg-muted/70", "focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", !enabled && "opacity-60", )} @@ -475,17 +442,9 @@ function SkillDetailSheet({ ) : (
-
-

- {t("settings.skills.enabledControl", { defaultValue: "Use this skill" })} -

-

- {t("settings.skills.enabledDescription", { - defaultValue: - "Allow the agent to load this skill when its requirements are ready.", - })} -

-
+

+ {t("settings.skills.enabledControl", { defaultValue: "Use this skill" })} +

- ))} -
+ className="w-fit bg-settings-surface" + itemClassName="inline-flex h-7 items-center gap-1.5" + options={providers.map((provider) => ({ + value: provider, + label: ( + <> + {provider !== "all" ? : null} + {provider === "all" + ? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" }) + : providerLabel(provider)} + + ), + }))} + onChange={onChange} + /> ); } @@ -420,13 +409,13 @@ function MarketplaceSkillGroups({ ); } return ( -
+
{providers.map((provider) => { const providerSkills = skills.filter((skill) => skill.provider === provider); if (!providerSkills.length) return null; return ( -
-
+
+
void; }) { return ( -
+
{skills.map((skill) => ( +
{Array.from({ length: 5 }, (_, index) => (
diff --git a/webui/src/components/settings/TokenUsageHeatmap.tsx b/webui/src/components/settings/TokenUsageHeatmap.tsx index 09139c91e..82b98eedf 100644 --- a/webui/src/components/settings/TokenUsageHeatmap.tsx +++ b/webui/src/components/settings/TokenUsageHeatmap.tsx @@ -232,7 +232,7 @@ export function TokenUsageHeatmap({ {label} {breakdown ? ( diff --git a/webui/src/components/settings/channels/ChannelIdentity.tsx b/webui/src/components/settings/channels/ChannelIdentity.tsx index 514e74016..e947292e5 100644 --- a/webui/src/components/settings/channels/ChannelIdentity.tsx +++ b/webui/src/components/settings/channels/ChannelIdentity.tsx @@ -136,7 +136,7 @@ export function ChannelLogo({ if (showBrandLogos && logoUrl) { return ( @@ -165,7 +165,7 @@ export function ChannelLogo({ return ( diff --git a/webui/src/components/settings/channels/ChannelInstancesPanel.tsx b/webui/src/components/settings/channels/ChannelInstancesPanel.tsx index 28709289c..59458adb6 100644 --- a/webui/src/components/settings/channels/ChannelInstancesPanel.tsx +++ b/webui/src/components/settings/channels/ChannelInstancesPanel.tsx @@ -177,7 +177,7 @@ export function ChannelInstancesPanel({
{expanded ? ( -
-
+
+

{customization.renderInstanceSummary?.(instance) ?? instance.id} @@ -256,7 +256,7 @@ export function ChannelInstancesPanel({ } /> {instanceFields.length ? ( -

+
{tx("settings.channels.advanced", "Advanced")} @@ -290,8 +290,8 @@ export function ChannelInstancesPanel({ + ); + })} +
+ ); +} diff --git a/webui/src/components/ui/sheet.tsx b/webui/src/components/ui/sheet.tsx index 88538e982..14bd0affd 100644 --- a/webui/src/components/ui/sheet.tsx +++ b/webui/src/components/ui/sheet.tsx @@ -16,7 +16,7 @@ const SheetOverlay = React.forwardRef< { expect(screen.getByText("cron")).toBeInTheDocument(); expect(screen.getByText("github")).toBeInTheDocument(); expect(screen.getByText("Needs setup")).toBeInTheDocument(); + expect( + screen.queryByText("Review the instruction skills this agent can load during a conversation."), + ).not.toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument(); expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute( @@ -534,6 +537,11 @@ describe("App layout", () => { "true", ); expect(screen.getByText("Setup required")).toBeInTheDocument(); + expect( + screen.queryByText( + "Allow the agent to load this skill when its requirements are ready.", + ), + ).not.toBeInTheDocument(); expect(screen.getByText("brew install gh")).toBeInTheDocument(); expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument(); expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument(); @@ -737,6 +745,9 @@ describe("App layout", () => { expect( await screen.findByRole("heading", { name: "Trending by marketplace" }), ).toBeInTheDocument(); + expect( + screen.queryByText("Each marketplace keeps its own ranking and install metrics."), + ).not.toBeInTheDocument(); expect(screen.getByText("find-skills")).toBeInTheDocument(); expect(screen.getByText("ima-skills")).toBeInTheDocument(); expect(screen.getAllByText("SkillHub")).toHaveLength(2); @@ -1994,6 +2005,11 @@ describe("App layout", () => { fireEvent.click(await screen.findByRole("menuitem", { name: "Appearance" })); expect(screen.getByText("Brand logos")).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument(); + expect( + screen.queryByText("Switch between light and dark appearance."), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Choose the language used by the WebUI.")).not.toBeInTheDocument(); + expect(screen.queryByText("Stored only in this browser.")).not.toBeInTheDocument(); expect(within(settingsNav).getByRole("button", { name: "Settings: Appearance" })).toBeInTheDocument(); fireEvent.pointerDown(within(settingsNav).getByRole("button", { name: "Settings: Appearance" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Models" })); @@ -2079,6 +2095,14 @@ describe("App layout", () => { expect(screen.getByRole("button", { name: "openai/gpt-5.4-image-2" })).toBeInTheDocument(); expect(screen.getByText("Save directory")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect( + screen.queryByText( + "Expose generate_image in chats when a configured image provider is available.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Choose a model supported by the selected image provider."), + ).not.toBeInTheDocument(); fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" })); expect(screen.getByText("Search provider")).toBeInTheDocument(); @@ -2086,6 +2110,12 @@ describe("App layout", () => { expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument(); expect(screen.getByTestId("provider-picker-logo-brave")).toBeInTheDocument(); expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); + expect( + screen.queryByText("Choose the backend used by the web search tool."), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Results returned by each web_search call."), + ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Edit" })); fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), { target: { value: "unsaved-brave-key" }, @@ -2098,8 +2128,14 @@ describe("App layout", () => { expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument(); fireEvent.click(within(settingsNav).getByRole("button", { name: "System" })); - expect(screen.getByText("Regional")).toBeInTheDocument(); + expect(screen.queryByText("Regional")).not.toBeInTheDocument(); expect(screen.getByText("Timezone")).toBeInTheDocument(); + expect( + screen.queryByText("Used for schedules and time-aware replies."), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Restart nanobot to apply runtime changes."), + ).not.toBeInTheDocument(); expect(screen.queryByText("Bot name")).not.toBeInTheDocument(); expect(screen.queryByText("Bot icon")).not.toBeInTheDocument(); expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument(); @@ -2107,16 +2143,15 @@ describe("App layout", () => { expect(screen.queryByText("Dream")).not.toBeInTheDocument(); expect(screen.queryByText("Unified session")).not.toBeInTheDocument(); expect(screen.getByText("Default workspace")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "UTC" })); - const timezoneSearch = await screen.findByPlaceholderText("Search timezone"); - expect(timezoneSearch).toBeInTheDocument(); - fireEvent.change(timezoneSearch, { - target: { value: "Shanghai" }, - }); - await user.click(screen.getByRole("option", { name: /Asia\/Shanghai/ })); - expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + expect(screen.getByText("UTC")).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Search timezone")).not.toBeInTheDocument(); + expect(screen.queryByRole("listbox", { name: "Select timezone" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "UTC" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + expect( + screen.queryByText("Connect SDKs and agents through a local /v1 endpoint."), + ).not.toBeInTheDocument(); + expect(screen.queryByText("The API uses this local port.")).not.toBeInTheDocument(); }); it("restores the settings section from the URL hash after a page reload", async () => { @@ -2130,6 +2165,41 @@ describe("App layout", () => { expect(window.location.hash).toBe("#/settings?section=voice"); }); + it("keeps the backend timezone without writing settings on mount", async () => { + const initialSettings = baseSettingsPayload(); + mockFetchRoutes({ + "/api/settings": initialSettings, + }); + const fetchMock = vi.mocked(fetch); + window.history.replaceState(null, "", "/#/settings?section=runtime"); + + render(); + + expect(await screen.findByText("UTC")).toBeInTheDocument(); + expect( + fetchMock.mock.calls.filter(([input]) => + String(input).startsWith("/api/settings/update?timezone="), + ), + ).toHaveLength(0); + expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument(); + expect( + screen.queryByText("Used for schedules and time-aware replies."), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Search timezone")).not.toBeInTheDocument(); + const systemSection = screen.getByRole("heading", { name: "System" }).closest("section"); + expect(systemSection).not.toBeNull(); + const system = within(systemSection as HTMLElement); + const timezoneLabel = system.getByText("Timezone"); + const restartButton = system.getByRole("button", { name: "Restart nanobot" }); + expect( + timezoneLabel.compareDocumentPosition(restartButton) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + system.queryByText("Restart nanobot to apply runtime changes."), + ).not.toBeInTheDocument(); + }); + it("falls back to Overview for the retired Files settings URL", async () => { mockFetchRoutes({ "/api/settings": baseSettingsPayload() }); window.history.replaceState(null, "", "/#/settings?section=files"); @@ -2211,6 +2281,7 @@ describe("App layout", () => { fireEvent.click(appsButton); expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument(); + expect(screen.queryByText("Add tools to nanobot, then @ them in chat.")).not.toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument(); expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute( diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index db423d486..ea57dd59f 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -113,6 +113,38 @@ describe("MessageBubble", () => { expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument(); }); + it("does not replay an entrance animation when persisted messages mount", () => { + const messages: UIMessage[] = [ + { + id: "u-persisted", + role: "user", + content: "Earlier question", + createdAt: Date.now(), + }, + { + id: "a-persisted", + role: "assistant", + content: "Earlier answer", + createdAt: Date.now(), + }, + { + id: "t-persisted", + role: "tool", + kind: "trace", + content: "Earlier tool call", + createdAt: Date.now(), + }, + ]; + + for (const message of messages) { + const { container, unmount } = render(); + for (const className of ["animate-in", "fade-in-0", "slide-in-from-bottom-1"]) { + expect(container.firstElementChild).not.toHaveClass(className); + } + unmount(); + } + }); + it("renders failed delivery details on focus without persistent accepted chrome", async () => { const message: UIMessage = { id: "u-delivery", diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 7ac35d2db..680fd5d52 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -610,14 +610,16 @@ describe("SettingsView Apps catalog", () => { renderSettingsView({ initialSection: "apps" }); - expect(await screen.findByText("Add tools to nanobot, then @ them in chat.")).toBeInTheDocument(); + expect(await screen.findByText("AnyGen")).toBeInTheDocument(); + expect( + screen.queryByText("Add tools to nanobot, then @ them in chat."), + ).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument(); expect(screen.queryByText("Api")).not.toBeInTheDocument(); - expect(screen.getByText("AnyGen")).toBeInTheDocument(); - expect(screen.getByText("0 ready")).toBeInTheDocument(); + expect(screen.queryByText("0 ready")).not.toBeInTheDocument(); }); it("shows nanobot optional features and enables one", async () => { @@ -787,7 +789,7 @@ describe("SettingsView Apps catalog", () => { renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument(); - expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument(); + expect(screen.queryByText("0 running · 1 channels")).not.toBeInTheDocument(); expect(screen.getAllByText("Failed").length).toBeGreaterThan(0); expect(screen.queryByText("Enabled, support needs install")).not.toBeInTheDocument(); @@ -841,7 +843,7 @@ describe("SettingsView Apps catalog", () => { renderSettingsView({ initialSection: "channels" }); expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument(); - expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument(); + expect(screen.queryByText("0 running · 1 channels")).not.toBeInTheDocument(); expect(screen.getAllByText("Failed").length).toBeGreaterThan(0); expect(screen.getByText(runtimeError)).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute( @@ -1353,7 +1355,7 @@ describe("SettingsView Apps catalog", () => { renderSettingsView({ initialSection: "channels" }); await screen.findByText("No assistant connected"); - expect(screen.getByText("0 running · 1 channels")).toBeInTheDocument(); + expect(screen.queryByText("0 running · 1 channels")).not.toBeInTheDocument(); expect(screen.getAllByText("Failed").length).toBeGreaterThan(0); expect(screen.getByText(runtimeError)).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "test assistant" })).toHaveAttribute(