mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 08:58:34 +00:00
refactor(webui): improve visual consistency (#5249)
This commit is contained in:
parent
a54d5d14cb
commit
9098ffd38f
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
19
nanobot/config/timezone.py
Normal file
19
nanobot/config/timezone.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
123
tests/config/test_timezone.py
Normal file
123
tests/config/test_timezone.py
Normal 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"
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
>
|
||||
|
||||
@ -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
|
||||
>
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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` }}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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`;
|
||||
|
||||
62
webui/src/components/ui/segmented-control.tsx
Normal file
62
webui/src/components/ui/segmented-control.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
),
|
||||
},
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user