refactor(webui): improve visual consistency (#5249)

This commit is contained in:
chengyongru 2026-08-05 13:24:45 +08:00 committed by GitHub
parent a54d5d14cb
commit 9098ffd38f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 578 additions and 646 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ? (
<div
role="status"
className="fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 max-w-[calc(100vw-1rem)] -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
className={cn(
floatingSurfaceElevationClassName,
"fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 max-w-[calc(100vw-1rem)] -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium",
)}
>
{restartToast}
</div>

View File

@ -38,7 +38,7 @@ export function DeleteConfirm({
return (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent
className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl data-[state=open]:zoom-in-95 sm:rounded-[28px]"
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
>
<AlertDialogHeader className="items-center space-y-0 text-center">
<div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive">
@ -89,7 +89,7 @@ export function DeleteConfirm({
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-none hover:bg-destructive/90"
>
{hasAutomations
? t("deleteConfirm.confirmWithAutomations")

View File

@ -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}

View File

@ -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 <TraceGroup message={message} animClass={baseAnim} />;
return <TraceGroup message={message} />;
}
if (message.role === "user") {
@ -314,12 +313,7 @@ export function MessageBubble({
/>
);
return (
<div
className={cn(
"group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5",
baseAnim,
)}
>
<div className="group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5">
{hasImages ? <UserImages images={images} align="right" /> : null}
{!hasImages && hasMedia ? (
<MessageMedia media={media} align="right" />
@ -410,7 +404,7 @@ export function MessageBubble({
message.role === "assistant"
&& (!empty || hasReasoning || media.length > 0);
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
<div className="w-full text-[15px]" style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
<ReasoningBubble
text={reasoning}
@ -836,7 +830,6 @@ export function ReasoningBubble({
interface TraceGroupProps {
message: UIMessage;
animClass: string;
}
/**
@ -844,13 +837,13 @@ interface TraceGroupProps {
* collapsed because tool traces are supporting evidence, not the answer.
* A single click expands the exact calls when the user wants details.
*/
export function TraceGroup({ message, animClass }: TraceGroupProps) {
export function TraceGroup({ message }: TraceGroupProps) {
const { t } = useTranslation();
const lines = message.traces ?? [message.content];
const count = lines.length;
const [open, setOpen] = useState(false);
return (
<div className={cn("w-full", animClass)}>
<div className="w-full">
<button
type="button"
onClick={() => setOpen((v) => !v)}

View File

@ -44,7 +44,7 @@ export function RenameChatDialog({
<Dialog open={open} onOpenChange={(next) => {
if (!next) onCancel();
}}>
<DialogContent className="max-w-sm rounded-[22px] border-border/70 bg-popover p-5 shadow-2xl">
<DialogContent className="max-w-sm p-5">
<form
className="grid gap-4"
onSubmit={(event) => {

View File

@ -120,8 +120,7 @@ export function SessionSearchDialog({
showCloseButton={false}
className={cn(
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
"rounded-[22px] border border-border bg-background text-foreground shadow-[0_22px_70px_rgba(0,0,0,0.22)]",
"dark:border-white/14 dark:bg-popover dark:shadow-[0_26px_90px_rgba(0,0,0,0.44)] sm:rounded-[22px]",
"rounded-[22px]",
)}
>
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>

View File

@ -109,6 +109,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Textarea } from "@/components/ui/textarea";
import { isLoopbackHost } from "@/lib/network";
import {
@ -144,7 +145,6 @@ import {
updateModelConfiguration,
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateTranscriptionSettings,
updateWebSearchSettings,
} from "@/lib/api";
@ -349,30 +349,6 @@ const SETTINGS_SEARCH_INPUT_CLASS = cn(
"focus-visible:ring-0 focus-visible:ring-offset-0",
);
const FALLBACK_TIMEZONES = [
"UTC",
"Asia/Shanghai",
"Asia/Hong_Kong",
"Asia/Tokyo",
"Asia/Seoul",
"Asia/Singapore",
"Asia/Taipei",
"Asia/Dubai",
"Asia/Kolkata",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Amsterdam",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Toronto",
"America/Sao_Paulo",
"Australia/Sydney",
"Pacific/Auckland",
];
interface CustomMcpForm {
name: string;
transport: CustomMcpTransport;
@ -426,7 +402,6 @@ interface SettingsViewProps {
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
skills?: SkillSummary[];
onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void;
onRestart?: () => void;
@ -631,7 +606,6 @@ export function SettingsView({
onModelNameChange,
onSettingsChange,
skills = [],
onWorkspaceSettingsChange,
onSectionChange,
onLogout,
onRestart,
@ -841,7 +815,9 @@ export function SettingsView({
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
@ -1082,11 +1058,6 @@ export function SettingsView({
);
}, [form, settings]);
const runtimeDirty = useMemo(() => {
if (!settings) return false;
return form.timezone !== settings.agent.timezone;
}, [form, settings]);
const imageGenerationDirty = useMemo(() => {
if (!settings) return false;
return (
@ -1399,27 +1370,6 @@ export function SettingsView({
}
};
const saveRuntimeSettings = async () => {
if (!settings || !runtimeDirty || saving) return;
setSaving(true);
try {
const payload = await updateSettings(token, {
timezone: form.timezone,
});
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await onWorkspaceSettingsChange?.();
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const saveImageGenerationSettings = async () => {
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true);
@ -2276,11 +2226,7 @@ export function SettingsView({
return (
<RuntimeSettings
form={form}
setForm={setForm}
settings={settings}
dirty={runtimeDirty}
saving={saving}
onSave={saveRuntimeSettings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
@ -2902,10 +2848,7 @@ function AppearanceSettings({
<section>
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={t("settings.rows.theme")}
description={t("settings.help.theme")}
>
<SettingsRow title={t("settings.rows.theme")}>
<button
type="button"
onClick={onToggleTheme}
@ -2932,10 +2875,7 @@ function AppearanceSettings({
</button>
</SettingsRow>
<SettingsRow
title={t("settings.rows.language")}
description={t("settings.help.language")}
>
<SettingsRow title={t("settings.rows.language")}>
<LanguageSwitcher />
</SettingsRow>
</SettingsGroup>
@ -2944,10 +2884,7 @@ function AppearanceSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.density", "Density")}
description={tx("settings.help.density", "Stored only in this browser.")}
>
<SettingsRow title={tx("settings.rows.density", "Density")}>
<SegmentedControl
value={localPrefs.density}
options={[
@ -2959,10 +2896,7 @@ function AppearanceSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.activityMode", "Activity detail")}
description={tx("settings.help.activityMode", "Choose how much agent activity chrome to show by default.")}
>
<SettingsRow title={tx("settings.rows.activityMode", "Activity detail")}>
<SegmentedControl
value={localPrefs.activityMode}
options={[
@ -2974,10 +2908,7 @@ function AppearanceSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.fileEditDisplay", "File edit display")}
description={tx("settings.help.fileEditDisplay", "Choose whether file edit activity opens as line counts or a diff.")}
>
<SettingsRow title={tx("settings.rows.fileEditDisplay", "File edit display")}>
<SegmentedControl
value={localPrefs.fileEditDisplayMode}
options={[
@ -2993,10 +2924,7 @@ function AppearanceSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.codeWrap", "Code wrapping")}
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
>
<SettingsRow title={tx("settings.rows.codeWrap", "Code wrapping")}>
<ToggleButton
checked={localPrefs.codeWrap}
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
@ -3004,10 +2932,7 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
>
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
@ -4854,10 +4779,7 @@ function ImageGenerationSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.imageGeneration", "Image generation")}
description={tx("settings.help.imageGeneration", "Expose generate_image in chats when a configured image provider is available.")}
>
<SettingsRow title={tx("settings.rows.imageGeneration", "Image generation")}>
<ToggleButton
checked={form.enabled}
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
@ -4865,10 +4787,7 @@ function ImageGenerationSettings({
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.imageProvider", "Image provider")}
description={tx("settings.help.imageProvider", "Choose the registry provider used by generate_image.")}
>
<SettingsRow title={tx("settings.rows.imageProvider", "Image provider")}>
<ProviderPicker
providers={settings.image_generation.providers}
value={form.provider}
@ -4905,10 +4824,7 @@ function ImageGenerationSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.imageModel", "Image model")}
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
>
<SettingsRow title={tx("settings.rows.imageModel", "Image model")}>
<ModelIdPicker
token={token}
settings={settings}
@ -4928,10 +4844,7 @@ function ImageGenerationSettings({
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.defaultAspectRatio", "Default aspect")}
description={tx("settings.help.defaultAspectRatio", "Used when the prompt does not choose an aspect ratio.")}
>
<SettingsRow title={tx("settings.rows.defaultAspectRatio", "Default aspect")}>
<ProviderPicker
providers={aspectOptions}
value={form.defaultAspectRatio}
@ -4941,10 +4854,7 @@ function ImageGenerationSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.defaultImageSize", "Default size")}
description={tx("settings.help.defaultImageSize", "Size hint sent to providers that support it.")}
>
<SettingsRow title={tx("settings.rows.defaultImageSize", "Default size")}>
<ProviderPicker
providers={sizeOptions}
value={form.defaultImageSize}
@ -4954,10 +4864,7 @@ function ImageGenerationSettings({
}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}
description={tx("settings.help.maxImagesPerTurn", "Upper bound for one generate_image request.")}
>
<SettingsRow title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}>
<NumberInput
value={form.maxImagesPerTurn}
min={1}
@ -5038,10 +4945,7 @@ function TranscriptionSettings({
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionProvider", "Provider")}
description={tx("settings.help.transcriptionProvider", "Uses the matching provider credentials from Providers.")}
>
<SettingsRow title={tx("settings.rows.transcriptionProvider", "Provider")}>
<ProviderPicker
providers={transcription.providers}
value={form.provider}
@ -5208,10 +5112,7 @@ function WebSettings({
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
) : null}
<SettingsGroup>
<SettingsRow
title={t("settings.byok.webSearch.provider")}
description={t("settings.byok.webSearch.providerHelp")}
>
<SettingsRow title={t("settings.byok.webSearch.provider")}>
<ProviderPicker
providers={settings.web_search.providers}
value={form.provider}
@ -5222,10 +5123,7 @@ function WebSettings({
</SettingsRow>
{selectedProvider?.credential === "none" ? (
<SettingsRow
title={t("settings.byok.webSearch.credentials")}
description={t("settings.byok.webSearch.noCredentialHelp")}
>
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
</SettingsRow>
) : null}
@ -5310,10 +5208,7 @@ function WebSettings({
<section>
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.maxResults", "Max results")}
description={tx("settings.help.maxResults", "Results returned by each web_search call.")}
>
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
<NumberInput
value={form.maxResults ?? settings.web_search.max_results}
min={1}
@ -5321,10 +5216,7 @@ function WebSettings({
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.timeout", "Timeout")}
description={tx("settings.help.timeout", "Seconds before a search provider request times out.")}
>
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
<NumberInput
value={form.timeout ?? settings.web_search.timeout}
min={1}
@ -6287,7 +6179,7 @@ function NanobotFeatureInstallDialog({
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl sm:rounded-[28px]"
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
>
<DialogHeader className="items-center space-y-0 text-center">
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
@ -7074,23 +6966,6 @@ function ChannelsSettings({
>
{!showingCompactDetail ? (
<section className="shrink-0 space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{tx(
"settings.channels.description",
"Connect chat apps, email, and WebUI to nanobot.",
)}
</p>
<div className="flex flex-wrap gap-2 text-[12px] font-medium text-muted-foreground">
<span className="rounded-full bg-muted/70 px-2.5 py-1">
{t("settings.channels.caption", {
enabled: enabledCount,
total: allChannels.length,
defaultValue: "{{enabled}} enabled · {{total}} channels",
})}
</span>
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
@ -7315,24 +7190,9 @@ function AppsCatalogSettings({
mcpError ||
(!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError);
const readyCount = (cliApps?.installed_count ?? 0) + (mcpPresets?.installed_count ?? 0);
const caption = t("settings.apps.enabledSummary", {
count: readyCount,
defaultValue: "{{count}} ready",
});
return (
<div className="space-y-7">
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{tx(
"settings.apps.description",
"Add tools to nanobot, then @ them in chat.",
)}
</p>
<span className="text-[12px] font-medium text-muted-foreground">{caption}</span>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
@ -8299,11 +8159,7 @@ function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos:
function RuntimeSettings({
form,
setForm,
settings,
dirty,
saving,
onSave,
onRestart,
isRestarting,
requiresRestartPending,
@ -8319,11 +8175,7 @@ function RuntimeSettings({
onInstallCapability,
}: {
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload;
dirty: boolean;
saving: boolean;
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
@ -8419,42 +8271,6 @@ function RuntimeSettings({
};
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.regional", "Regional")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.timezone", "Timezone")}
description={tx(
"settings.help.timezone",
"Used for schedules and time-aware replies.",
)}
>
<TimezonePicker
value={form.timezone}
onChange={(timezone) => setForm((prev) => ({ ...prev, timezone }))}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
dirtyMessage={
isNativeHost
? tx("settings.status.hostRestartAfterSaving", "Save changes and nanobot will restart its engine.")
: tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
}
pendingMessage={
isNativeHost
? tx("settings.status.hostRestartPending", "Saved. Restarting engine when ready.")
: tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
{isNativeHost ? (
<section>
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
@ -8464,9 +8280,7 @@ function RuntimeSettings({
<SettingsRow
title={tx("settings.rows.logs", "Logs")}
description={
hostActionMessage?.target === "logs"
? hostActionMessage.message
: tx("settings.help.logs", "Open the native engine log folder.")
hostActionMessage?.target === "logs" ? hostActionMessage.message : undefined
}
>
<Button
@ -8495,9 +8309,7 @@ function RuntimeSettings({
description={
hostActionMessage?.target === "diagnostics"
? hostActionMessage.message
: diagnosticsPath
? diagnosticsPath
: tx("settings.help.diagnostics", "Export a small runtime report for support.")
: diagnosticsPath || undefined
}
>
<Button
@ -8542,7 +8354,7 @@ function RuntimeSettings({
? apiServiceError
: apiDefaults.running
? apiDefaults.endpoint
: tx("settings.api.description", "Connect SDKs and agents through a local /v1 endpoint.")
: undefined
}
>
<div className="flex items-center justify-end gap-2">
@ -8608,10 +8420,7 @@ function RuntimeSettings({
onChange={(value) => setApiHost(value === "network" ? "0.0.0.0" : "127.0.0.1")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.api.port", "Port")}
description={tx("settings.api.portHelp", "The API uses this local port.")}
>
<SettingsRow title={tx("settings.api.port", "Port")}>
<NumberInput value={apiPort} min={1} max={65535} onChange={setApiPort} />
</SettingsRow>
{apiNetworkAccess ? (
@ -8647,11 +8456,6 @@ function RuntimeSettings({
</>
) : null}
</SettingsGroup>
{!apiDefaults.installed && !apiDefaults.running ? (
<p className="mt-2 text-[11.5px] text-muted-foreground">
{tx("settings.api.autoInstall", "API support will be installed automatically when you start it.")}
</p>
) : null}
</section>
<section>
@ -8661,7 +8465,7 @@ function RuntimeSettings({
title="Langfuse"
description={
settings.observability?.configured
? tx("settings.observability.configured", "Tracing credentials are available to nanobot.")
? undefined
: tx(
"settings.observability.environment",
"Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
@ -8708,10 +8512,15 @@ function RuntimeSettings({
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
{onRestart && !requiresRestartPending ? (
<ReadOnlyRow title={tx("settings.rows.timezone", "Timezone")} value={form.timezone} />
{onRestart ? (
<SettingsRow
title={t("settings.rows.restart")}
description={t("app.system.restartHint")}
description={
requiresRestartPending
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: undefined
}
>
<Button
size="sm"
@ -8829,117 +8638,6 @@ function AdvancedSettings({
);
}
function TimezonePicker({
value,
onChange,
}: {
value: string;
onChange: (timezone: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const options = useMemo(() => timezoneOptions(value), [value]);
const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]);
const optionValues = useMemo(
() => filteredOptions.map((option) => option.name),
[filteredOptions],
);
const chooseTimezone = (timezone: string) => {
onChange(timezone);
setOpen(false);
};
const navigation = useComboboxNavigation({
open,
values: optionValues,
selectedValue: value,
onSelect: chooseTimezone,
onClose: () => setOpen(false),
});
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-8 w-[220px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[340px] max-w-[calc(100vw-2rem)]"
>
<div className="sticky top-0 z-10 bg-popover px-1 pb-1">
<div className="flex h-9 items-center gap-2 rounded-full border border-input bg-background px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<Input
autoFocus
value={query}
onChange={(event) => 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"
/>
</div>
</div>
{filteredOptions.length ? (
<div
{...navigation.listProps}
aria-label={tx("settings.timezone.select", "Select timezone")}
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
data-testid="timezone-picker-list"
>
{filteredOptions.map((option) => {
const selected = option.name === value;
return (
<ComboboxOption
key={option.name}
{...navigation.getOptionProps(option.name)}
className={cn(
"flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]",
selected && "text-foreground",
)}
>
<span className="min-w-0 truncate font-medium text-foreground">{option.name}</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<span className="text-[11.5px] font-medium text-muted-foreground/80">
{option.offset}
</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</span>
</ComboboxOption>
);
})}
</div>
) : (
<div
role="status"
className="px-3 py-5 text-center text-[12px] text-muted-foreground"
data-testid="timezone-picker-list"
>
{tx("settings.timezone.empty", "No matching timezones.")}
</div>
)}
</PopoverContent>
</Popover>
);
}
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 (
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
"rounded-full px-3 py-1 transition-colors",
value === option.value &&
"bg-background text-foreground ring-1 ring-inset ring-border/45",
)}
>
{option.label}
</button>
))}
</div>
);
}
function NumberInput({
value,
min,

View File

@ -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<SkillSummary | null>(null);
const [view, setView] = useState<"installed" | "discover">("installed");
const [installingSkill, setInstallingSkill] = useState("");
@ -80,51 +78,28 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue:
"Review installed skills or discover new capabilities from the skills.sh catalog.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.skills.caption", {
available: availableCount,
total: skills.length,
defaultValue: "{{available}} available · {{total}} total",
})}
</span>
</section>
<div
className="inline-flex rounded-[12px] bg-muted/65 p-1"
role="tablist"
aria-label={t("settings.skills.views", { defaultValue: "Skills views" })}
>
{(["installed", "discover"] as const).map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={view === item}
onClick={() => setView(item)}
className={cn(
"inline-flex items-center rounded-[9px] px-3.5 py-1.5 text-[13px] font-medium transition-colors",
view === item
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{item === "installed"
? t("settings.skills.installedTab", { defaultValue: "Installed" })
: t("settings.skills.discoverTab", { defaultValue: "Discover" })}
</button>
))}
</div>
<SegmentedControl
value={view}
mode="tabs"
ariaLabel={t("settings.skills.views", { defaultValue: "Skills views" })}
className="w-fit text-[13px]"
itemClassName="px-3.5"
options={[
{
value: "installed",
label: t("settings.skills.installedTab", { defaultValue: "Installed" }),
},
{
value: "discover",
label: t("settings.skills.discoverTab", { defaultValue: "Discover" }),
},
]}
onChange={setView}
/>
{view === "installed" ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col gap-3 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-[320px]">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
@ -142,13 +117,11 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
/>
</div>
<div
className={cn(
"flex max-w-full items-center gap-1 overflow-x-auto rounded-[10px] bg-muted/65 p-1",
"scrollbar-thin scrollbar-track-transparent sm:w-auto",
)}
>
{([
<SegmentedControl
value={installedFilter}
className="sm:w-auto"
itemClassName="px-2.5 text-[11px]"
options={([
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
[
"enabled",
@ -160,28 +133,22 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
t("settings.skills.filterDisabled", { defaultValue: "Disabled" }),
disabledCount,
],
] as const).map(([filter, label, count]) => (
<button
key={filter}
type="button"
onClick={() => setInstalledFilter(filter)}
className={cn(
"shrink-0 whitespace-nowrap rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
installedFilter === filter
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
</button>
))}
</div>
] as const).map(([value, label, count]) => ({
value,
label: (
<>
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
</>
),
}))}
onChange={setInstalledFilter}
/>
</div>
{groupedSkills.length ? (
<div className="pb-2">
<div className="space-y-5 px-3 pb-3 pt-2 sm:px-4">
{groupedSkills.map((group) => (
<section key={group.key}>
<div className="flex items-center gap-2 bg-muted/20 px-5 py-2.5">
<section key={group.key} className="space-y-1">
<div className="flex items-center gap-2 px-2 py-1.5">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{group.label}
</h2>
@ -189,7 +156,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
{group.skills.length}
</span>
</div>
<div className="divide-y divide-border/40 px-3 sm:px-4">
<div className="space-y-1">
{group.skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
@ -254,8 +221,8 @@ function SkillCatalogRow({
onClick={() => 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({
) : (
<div className="mt-6 space-y-5">
<div className="flex min-h-16 items-start justify-between gap-3 border-y border-border/45 px-1 py-3.5">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.enabledDescription", {
defaultValue:
"Allow the agent to load this skill when its requirements are ready.",
})}
</p>
</div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
</p>
<button
type="button"
role="switch"

View File

@ -21,6 +21,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 {
fetchMarketplaceSkillTrends,
fetchTrendingMarketplaceSkills,
@ -232,19 +233,12 @@ export function SkillsMarketplace({
{query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div>
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending by marketplace",
})}
</h2>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingDescription", {
defaultValue: "Each marketplace keeps its own ranking and install metrics.",
})}
</p>
</div>
<div className="flex flex-col items-start gap-2 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending by marketplace",
})}
</h2>
{provider !== "all" ? (
<a
href={providerUrl(provider)}
@ -359,32 +353,27 @@ function ProviderFilter({
const { t } = useTranslation();
const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"];
return (
<div
className="flex w-fit items-center gap-0.5 rounded-full bg-settings-surface p-1"
role="tablist"
aria-label={t("settings.skills.marketplaceProviderFilter", {
<SegmentedControl
value={value}
mode="tabs"
ariaLabel={t("settings.skills.marketplaceProviderFilter", {
defaultValue: "Skill source",
})}
>
{providers.map((provider) => (
<button
key={provider}
type="button"
role="tab"
aria-selected={value === provider}
onClick={() => onChange(provider)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium text-muted-foreground transition-colors",
value === provider && "bg-background text-foreground shadow-sm",
)}
>
{provider !== "all" ? <ProviderDot provider={provider} /> : null}
{provider === "all"
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
: providerLabel(provider)}
</button>
))}
</div>
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" ? <ProviderDot provider={provider} /> : null}
{provider === "all"
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
: providerLabel(provider)}
</>
),
}))}
onChange={onChange}
/>
);
}
@ -420,13 +409,13 @@ function MarketplaceSkillGroups({
);
}
return (
<div>
<div className="space-y-5 pb-3 pt-2">
{providers.map((provider) => {
const providerSkills = skills.filter((skill) => skill.provider === provider);
if (!providerSkills.length) return null;
return (
<section key={provider} className="border-t border-border/45 first:border-t-0">
<div className="flex items-center justify-between px-5 pb-1 pt-3.5">
<section key={provider} className="space-y-1">
<div className="flex items-center justify-between px-5 py-1.5">
<ProviderMark provider={provider} />
<a
href={providerUrl(provider)}
@ -469,7 +458,7 @@ function MarketplaceSkillList({
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
return (
<div className="divide-y divide-border/45 px-3 sm:px-4">
<div className="space-y-1 px-3 pb-3 sm:px-4">
{skills.map((skill) => (
<MarketplaceSkillRow
key={skill.id}
@ -679,7 +668,7 @@ function TrendSparkline({ values }: { values?: number[] }) {
function TrendingSkeleton() {
return (
<div className="divide-y divide-border/45 px-5" aria-hidden>
<div className="space-y-1 px-5 pb-3" aria-hidden>
{Array.from({ length: 5 }, (_, index) => (
<div key={index} className="flex items-center gap-3 py-4">
<div className="h-3 w-5 animate-pulse rounded bg-muted" />

View File

@ -232,7 +232,7 @@ export function TokenUsageHeatmap({
<TooltipContent
side="top"
align="center"
className="rounded-[10px] border-border/45 bg-popover px-2.5 py-1.5 text-[11px] font-normal text-popover-foreground shadow-lg"
className="px-2.5 py-1.5 text-[11px] font-normal"
>
<span className="block">{label}</span>
{breakdown ? (

View File

@ -136,7 +136,7 @@ export function ChannelLogo({
if (showBrandLogos && logoUrl) {
return (
<span
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] border border-border/45 bg-background"
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background"
>
<img
src={logoUrl}
@ -154,7 +154,7 @@ export function ChannelLogo({
if (Icon) {
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background"
style={{ color }}
aria-hidden
>
@ -165,7 +165,7 @@ export function ChannelLogo({
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background text-[11px] font-bold"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
style={{ color }}
aria-hidden
>

View File

@ -177,7 +177,7 @@ export function ChannelInstancesPanel({
<article
key={instance.id}
className={cn(
"overflow-hidden rounded-[18px] border border-transparent transition-colors",
"overflow-hidden rounded-[18px] transition-colors",
expanded
? "bg-background"
: "bg-background/70 hover:bg-muted",
@ -230,8 +230,8 @@ export function ChannelInstancesPanel({
</div>
{expanded ? (
<div className="border-t border-border/60">
<section className="px-4 py-4">
<div className="space-y-5 px-4 pb-4">
<section className="pt-4">
<div className="mb-3 flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 truncate font-mono text-[11.5px] leading-6 text-muted-foreground">
{customization.renderInstanceSummary?.(instance) ?? instance.id}
@ -256,7 +256,7 @@ export function ChannelInstancesPanel({
}
/>
{instanceFields.length ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<details className="group text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
@ -290,8 +290,8 @@ export function ChannelInstancesPanel({
<Button
type="submit"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-muted/70 px-3 text-[12px] font-semibold hover:bg-muted"
disabled={savingFields}
>
{savingFields ? (
@ -397,7 +397,7 @@ function ChannelInstanceAvatar({
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background text-[10px] font-bold"
className="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-full bg-background text-[10px] font-bold"
style={{ color }}
aria-hidden
>

View File

@ -80,7 +80,7 @@ export function ChannelCatalogRow({
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] border border-transparent px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
selected ? "bg-background" : "hover:bg-muted",
)}
>
@ -194,7 +194,7 @@ export function ChannelSetupPanel({
<Button
type="button"
size="sm"
variant="outline"
variant="secondary"
disabled={enableBusy}
onClick={() => onAction("enable", feature.name)}
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
@ -396,13 +396,13 @@ function ChannelSetupSurface({
return (
<form
className="mt-5 overflow-hidden rounded-[16px] bg-background/55"
className="mt-5 space-y-5"
onSubmit={(event) => {
event.preventDefault();
if (mode === "credentials") void saveCredentialSettings();
}}
>
<section className="px-4 py-4">
<section>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[13px] font-semibold text-foreground">
{tx("settings.channels.requiredSetup", "Required setup")}
@ -444,8 +444,8 @@ function ChannelSetupSurface({
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
onClick={() =>
setNotice(
tx(
@ -461,7 +461,7 @@ function ChannelSetupSurface({
<Button
type="button"
size="sm"
variant="outline"
variant="secondary"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={copyCommand}
>
@ -498,8 +498,8 @@ function ChannelSetupSurface({
<Button
type="submit"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
disabled={saving}
>
{saving || validating ? (
@ -527,7 +527,7 @@ function ChannelSetupSurface({
{notice ? (
<div
role="status"
className="border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground"
className="rounded-[12px] bg-muted/55 px-3 py-2.5 text-[12px] leading-5 text-muted-foreground"
>
{notice}
</div>
@ -540,7 +540,7 @@ function ChannelSetupSurface({
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
{hasAdvanced ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<details className="group text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}

View File

@ -57,7 +57,7 @@ export function ChannelGuideLink({
target="_blank"
rel="noreferrer"
className={cn(
"inline-flex max-w-full items-center gap-2 border border-border/45 bg-background/90 font-semibold text-foreground transition-colors hover:bg-muted",
"inline-flex max-w-full items-center gap-2 bg-background/80 font-semibold text-foreground transition-colors hover:bg-background",
compact
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
@ -65,7 +65,7 @@ export function ChannelGuideLink({
>
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background font-bold",
"grid shrink-0 place-items-center overflow-hidden bg-muted/70 font-bold",
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
)}
style={{ color }}
@ -135,10 +135,10 @@ export function ChannelOfficialLink({
href={setup.officialUrl}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full border border-border/45 bg-background/90 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground transition-colors hover:bg-muted"
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full bg-background/80 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground transition-colors hover:bg-background"
>
<span
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background"
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full bg-muted/70"
style={{ color }}
aria-hidden
>
@ -182,8 +182,8 @@ export function ChannelSetupActions({
key={action.id}
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
variant="secondary"
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
onClick={() => {
if (action.copyText) {
void copyTextToClipboard(action.copyText).then((ok) =>
@ -246,8 +246,7 @@ export function ChannelProviderPresets({
}}
className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selected === preset.id
&& "bg-background text-foreground ring-1 ring-inset ring-border/45",
selected === preset.id && "bg-background text-foreground",
)}
>
{preset.label}
@ -307,7 +306,7 @@ export function ChannelValidationChecks({ validation }: { validation: ChannelVal
const { t } = useTranslation();
if (!validation.checks.length) return null;
return (
<div className="border-t border-border/60 px-4 py-4">
<div>
<div className="mb-2 text-[12px] font-semibold text-foreground">
{t("settings.channels.connectionChecks")}
</div>
@ -353,7 +352,7 @@ export function ChannelSetupSteps({
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="border-t border-border/60 px-4 py-4 text-[12.5px] leading-5 text-muted-foreground">
<div className="text-[12.5px] leading-5 text-muted-foreground">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[12px] font-semibold text-foreground">
{tx("settings.channels.setupSteps", "Next steps")}
@ -371,7 +370,7 @@ export function ChannelSetupSteps({
))}
</ol>
{tryIt ? (
<div className="mt-3 rounded-[12px] border border-border/55 bg-background px-3 py-2 text-[12px] text-muted-foreground">
<div className="mt-3 rounded-[12px] bg-background/75 px-3 py-2 text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">
{tx("settings.channels.tryIt", "Try it")}
</span>

View File

@ -3,6 +3,9 @@ import { createPortal } from "react-dom";
import { MessageCircleMore } from "lucide-react";
import { useTranslation } from "react-i18next";
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const MAX_QUOTED_CONTEXT_CHARS = 4_000;
interface SelectionActionState {
@ -142,7 +145,10 @@ export function AssistantSelectionAction({
ref={actionRef}
type="button"
data-selection-follow-up="true"
className="fixed z-[80] inline-flex h-9 max-w-[calc(100vw-24px)] items-center gap-1.5 rounded-full border border-border/80 bg-popover px-3 text-[13px] font-medium text-popover-foreground shadow-lg shadow-black/10 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:shadow-black/35"
className={cn(
floatingSurfaceElevationClassName,
"fixed z-[80] inline-flex h-9 max-w-[calc(100vw-24px)] items-center gap-1.5 rounded-full px-3 text-[13px] font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
style={{
left: action.left,
top: action.top,

View File

@ -10,6 +10,7 @@ import {
import { useTranslation } from "react-i18next";
import { MarkdownText } from "@/components/MarkdownText";
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import {
@ -203,8 +204,7 @@ export function PromptRail({
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
className={cn(
"pointer-events-none absolute left-10 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-[20px] px-4 py-3 text-left",
"bg-popover/95 text-popover-foreground shadow-[0_18px_45px_rgba(0,0,0,0.12)] backdrop-blur-xl",
"dark:shadow-[0_18px_45px_rgba(0,0,0,0.45)]",
floatingSurfaceElevationClassName,
"transition-[opacity,transform] duration-150",
previewVisible
? "translate-x-0 scale-100 opacity-100"

View File

@ -52,6 +52,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
floatingItemClassName,
floatingSurfaceElevationClassName,
floatingSurfaceVisualClassName,
} from "@/components/ui/floating-surface";
import {
@ -805,8 +806,8 @@ function RunElapsedStrip({
tabIndex={-1}
className={cn(
"absolute bottom-[calc(100%+8px)] left-3 right-3 z-[50] flex max-w-none flex-col overflow-hidden",
"rounded-2xl border border-black/[0.08] bg-card shadow-[0_12px_40px_rgba(15,23,42,0.14)]",
"backdrop-blur-sm dark:border-white/[0.1] dark:shadow-[0_16px_48px_rgba(0,0,0,0.45)]",
"rounded-2xl",
floatingSurfaceElevationClassName,
)}
style={{ maxHeight: `${Math.round(panelMaxPx)}px` }}
>

View File

@ -1,8 +1,12 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import {
modalOverlayClassName,
modalSurfaceClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
@ -14,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/45 backdrop-blur-[3px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
modalOverlayClassName,
className,
)}
{...props}
@ -32,7 +36,8 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
modalSurfaceClassName,
"grid w-full max-w-lg origin-center gap-4 rounded-[22px] p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}

View File

@ -3,6 +3,10 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
modalOverlayClassName,
modalSurfaceClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
@ -15,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
modalOverlayClassName,
className,
)}
{...props}
@ -40,7 +44,8 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
modalSurfaceClassName,
"grid w-full max-w-lg origin-center gap-4 rounded-[22px] p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}

View File

@ -1,5 +1,14 @@
export const floatingSurfaceElevationClassName =
"bg-popover text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.10)] dark:shadow-[0_12px_28px_rgba(0,0,0,0.32)]";
export const floatingSurfaceVisualClassName =
"rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
`rounded-[18px] p-1.5 ${floatingSurfaceElevationClassName}`;
export const modalOverlayClassName =
"fixed inset-0 z-50 bg-black/45 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
export const modalSurfaceClassName =
"bg-background text-foreground shadow-[0_12px_36px_rgba(15,23,42,0.12)] dark:bg-popover dark:shadow-[0_18px_44px_rgba(0,0,0,0.32)]";
export const floatingSurfaceClassName =
`${floatingSurfaceVisualClassName} z-50 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;

View File

@ -0,0 +1,62 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface SegmentedControlOption<T extends string> {
value: T;
label: ReactNode;
}
interface SegmentedControlProps<T extends string> {
value: T;
options: Array<SegmentedControlOption<T>>;
onChange: (value: T) => void;
ariaLabel?: string;
mode?: "buttons" | "tabs";
className?: string;
itemClassName?: string;
}
export function SegmentedControl<T extends string>({
value,
options,
onChange,
ariaLabel,
mode = "buttons",
className,
itemClassName,
}: SegmentedControlProps<T>) {
const tabs = mode === "tabs";
return (
<div
role={tabs ? "tablist" : undefined}
aria-label={ariaLabel}
className={cn(
"inline-flex min-h-8 max-w-full items-center gap-1 overflow-x-auto rounded-full bg-muted/65 p-1 text-[12px] font-medium text-muted-foreground",
"scrollbar-thin scrollbar-track-transparent",
className,
)}
>
{options.map((option) => {
const selected = value === option.value;
return (
<button
key={option.value}
type="button"
role={tabs ? "tab" : undefined}
aria-selected={tabs ? selected : undefined}
aria-pressed={tabs ? undefined : selected}
onClick={() => onChange(option.value)}
className={cn(
"shrink-0 whitespace-nowrap rounded-full px-3 py-1 text-muted-foreground transition-colors",
selected ? "bg-background text-foreground" : "hover:text-foreground",
itemClassName,
)}
>
{option.label}
</button>
);
})}
</div>
);
}

View File

@ -16,7 +16,7 @@ const SheetOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"fixed inset-0 z-50 bg-black/40",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
@ -27,24 +27,24 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out",
"fixed z-50 flex flex-col gap-4 bg-background transition ease-in-out",
{
variants: {
side: {
top: cn(
"inset-x-0 top-0 border-b",
"inset-x-0 top-0",
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
),
bottom: cn(
"inset-x-0 bottom-0 border-t",
"inset-x-0 bottom-0",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
),
left: cn(
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
"inset-y-0 left-0 h-full w-3/4 sm:max-w-sm",
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
),
right: cn(
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
"inset-y-0 right-0 h-full w-3/4 sm:max-w-sm",
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
),
},

View File

@ -1,6 +1,7 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
@ -16,7 +17,8 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
floatingSurfaceElevationClassName,
"z-50 overflow-hidden rounded-[10px] px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95",
className,
)}
{...props}

View File

@ -509,6 +509,9 @@ describe("App layout", () => {
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(<App />);
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(

View File

@ -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(<MessageBubble message={message} />);
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",

View File

@ -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(