mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +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.provider = "brave"
|
||||||
config.tools.web.search.api_key = "brave-secret"
|
config.tools.web.search.api_key = "brave-secret"
|
||||||
|
expected_timezone = config.agents.defaults.timezone
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
monkeypatch.setattr(
|
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"]["provider"] == "openai"
|
||||||
assert body["agent"]["model_preset"] == "default"
|
assert body["agent"]["model_preset"] == "default"
|
||||||
assert body["agent"]["max_tokens"] == 8192
|
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_name" not in body["agent"]
|
||||||
assert "bot_icon" not in body["agent"]
|
assert "bot_icon" not in body["agent"]
|
||||||
assert body["agent"]["tool_hint_max_length"] == 40
|
assert body["agent"]["tool_hint_max_length"] == 40
|
||||||
|
|||||||
@ -2,11 +2,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
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 import AliasChoices, ConfigDict, Field, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from nanobot.config.timezone import detect_system_timezone
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
|
|
||||||
@ -140,7 +141,8 @@ class AgentDefaults(Base):
|
|||||||
serialization_alias="toolHintMaxLength",
|
serialization_alias="toolHintMaxLength",
|
||||||
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
) # 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
|
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_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
|
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)
|
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)
|
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
|
||||||
dream: DreamConfig = Field(default_factory=DreamConfig)
|
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")
|
@field_validator("timezone")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_timezone(cls, value: str) -> str:
|
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)
|
ZoneInfo(timezone)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise WebUISettingsError("invalid timezone") from None
|
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 = timezone
|
||||||
|
defaults.timezone_mode = "manual"
|
||||||
changed = True
|
changed = True
|
||||||
restart_required = True
|
restart_required = timezone_changed
|
||||||
|
|
||||||
tool_hint_max_length = _query_first_alias(
|
tool_hint_max_length = _query_first_alias(
|
||||||
query,
|
query,
|
||||||
|
|||||||
@ -52,6 +52,7 @@ dependencies = [
|
|||||||
"watchfiles>=1.1.1,<2.0.0",
|
"watchfiles>=1.1.1,<2.0.0",
|
||||||
"packaging>=24.0",
|
"packaging>=24.0",
|
||||||
"tzdata>=2025.2",
|
"tzdata>=2025.2",
|
||||||
|
"tzlocal>=5.3.1,<6.0.0",
|
||||||
"defusedxml>=0.7.1,<1.0.0",
|
"defusedxml>=0.7.1,<1.0.0",
|
||||||
"pypdf>=5.0.0,<6.0.0",
|
"pypdf>=5.0.0,<6.0.0",
|
||||||
"python-docx>=1.1.0,<2.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
|
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(
|
def test_update_model_configuration_preserves_custom_context_windows(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { channelUiPresentation } from "@/channel-plugins/registry";
|
|||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
import { useSessions } from "@/hooks/useSessions";
|
import { useSessions } from "@/hooks/useSessions";
|
||||||
@ -479,8 +480,8 @@ function PairingCodePopup({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
|
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
|
||||||
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
|
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
|
||||||
"border border-border/70 bg-popover/95 p-4 text-popover-foreground",
|
floatingSurfaceElevationClassName,
|
||||||
"shadow-[0_24px_70px_rgba(15,23,42,0.20)] backdrop-blur-xl",
|
"p-4",
|
||||||
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
|
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -2128,7 +2129,6 @@ function Shell({
|
|||||||
onModelNameChange={onModelNameChange}
|
onModelNameChange={onModelNameChange}
|
||||||
onSettingsChange={setSettingsSnapshot}
|
onSettingsChange={setSettingsSnapshot}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
onWorkspaceSettingsChange={refreshWorkspaces}
|
|
||||||
onSectionChange={onSettingsSectionChange}
|
onSectionChange={onSettingsSectionChange}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onRestart={onRestart}
|
onRestart={onRestart}
|
||||||
@ -2179,7 +2179,10 @@ function Shell({
|
|||||||
{restartToast ? (
|
{restartToast ? (
|
||||||
<div
|
<div
|
||||||
role="status"
|
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}
|
{restartToast}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export function DeleteConfirm({
|
|||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||||
<AlertDialogContent
|
<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">
|
<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">
|
<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>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={onConfirm}
|
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
|
{hasAutomations
|
||||||
? t("deleteConfirm.confirmWithAutomations")
|
? t("deleteConfirm.confirmWithAutomations")
|
||||||
|
|||||||
@ -111,9 +111,8 @@ export function FileReferenceChip({
|
|||||||
collisionPadding={12}
|
collisionPadding={12}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
|
"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",
|
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
|
||||||
"shadow-lg backdrop-blur",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{fullPath}
|
{fullPath}
|
||||||
|
|||||||
@ -266,7 +266,6 @@ export function MessageBubble({
|
|||||||
onForkFromHere,
|
onForkFromHere,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
|
|
||||||
const mentionCliApps = useMemo(
|
const mentionCliApps = useMemo(
|
||||||
() => mergeCliMentionApps(cliApps, message.cliApps),
|
() => mergeCliMentionApps(cliApps, message.cliApps),
|
||||||
[cliApps, message.cliApps],
|
[cliApps, message.cliApps],
|
||||||
@ -277,7 +276,7 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (message.kind === "trace") {
|
if (message.kind === "trace") {
|
||||||
return <TraceGroup message={message} animClass={baseAnim} />;
|
return <TraceGroup message={message} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
@ -314,12 +313,7 @@ export function MessageBubble({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5">
|
||||||
className={cn(
|
|
||||||
"group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5",
|
|
||||||
baseAnim,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hasImages ? <UserImages images={images} align="right" /> : null}
|
{hasImages ? <UserImages images={images} align="right" /> : null}
|
||||||
{!hasImages && hasMedia ? (
|
{!hasImages && hasMedia ? (
|
||||||
<MessageMedia media={media} align="right" />
|
<MessageMedia media={media} align="right" />
|
||||||
@ -410,7 +404,7 @@ export function MessageBubble({
|
|||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
return (
|
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 ? (
|
{hasReasoning ? (
|
||||||
<ReasoningBubble
|
<ReasoningBubble
|
||||||
text={reasoning}
|
text={reasoning}
|
||||||
@ -836,7 +830,6 @@ export function ReasoningBubble({
|
|||||||
|
|
||||||
interface TraceGroupProps {
|
interface TraceGroupProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
animClass: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -844,13 +837,13 @@ interface TraceGroupProps {
|
|||||||
* collapsed because tool traces are supporting evidence, not the answer.
|
* collapsed because tool traces are supporting evidence, not the answer.
|
||||||
* A single click expands the exact calls when the user wants details.
|
* 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 { t } = useTranslation();
|
||||||
const lines = message.traces ?? [message.content];
|
const lines = message.traces ?? [message.content];
|
||||||
const count = lines.length;
|
const count = lines.length;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", animClass)}>
|
<div className="w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export function RenameChatDialog({
|
|||||||
<Dialog open={open} onOpenChange={(next) => {
|
<Dialog open={open} onOpenChange={(next) => {
|
||||||
if (!next) onCancel();
|
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
|
<form
|
||||||
className="grid gap-4"
|
className="grid gap-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
|
|||||||
@ -120,8 +120,7 @@ export function SessionSearchDialog({
|
|||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={cn(
|
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",
|
"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)]",
|
"rounded-[22px]",
|
||||||
"dark:border-white/14 dark:bg-popover dark:shadow-[0_26px_90px_rgba(0,0,0,0.44)] sm:rounded-[22px]",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
||||||
|
|||||||
@ -109,6 +109,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { isLoopbackHost } from "@/lib/network";
|
import { isLoopbackHost } from "@/lib/network";
|
||||||
import {
|
import {
|
||||||
@ -144,7 +145,6 @@ import {
|
|||||||
updateModelConfiguration,
|
updateModelConfiguration,
|
||||||
updateNetworkSafetySettings,
|
updateNetworkSafetySettings,
|
||||||
updateProviderSettings,
|
updateProviderSettings,
|
||||||
updateSettings,
|
|
||||||
updateTranscriptionSettings,
|
updateTranscriptionSettings,
|
||||||
updateWebSearchSettings,
|
updateWebSearchSettings,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@ -349,30 +349,6 @@ const SETTINGS_SEARCH_INPUT_CLASS = cn(
|
|||||||
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
"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 {
|
interface CustomMcpForm {
|
||||||
name: string;
|
name: string;
|
||||||
transport: CustomMcpTransport;
|
transport: CustomMcpTransport;
|
||||||
@ -426,7 +402,6 @@ interface SettingsViewProps {
|
|||||||
onModelNameChange: (modelName: string | null) => void;
|
onModelNameChange: (modelName: string | null) => void;
|
||||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||||
skills?: SkillSummary[];
|
skills?: SkillSummary[];
|
||||||
onWorkspaceSettingsChange?: () => void | Promise<void>;
|
|
||||||
onSectionChange?: (section: SettingsSectionKey) => void;
|
onSectionChange?: (section: SettingsSectionKey) => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
@ -631,7 +606,6 @@ export function SettingsView({
|
|||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
skills = [],
|
skills = [],
|
||||||
onWorkspaceSettingsChange,
|
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
onLogout,
|
onLogout,
|
||||||
onRestart,
|
onRestart,
|
||||||
@ -841,7 +815,9 @@ export function SettingsView({
|
|||||||
if (!cancelled && showLoading) setError((err as Error).message);
|
if (!cancelled && showLoading) setError((err as Error).message);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@ -1082,11 +1058,6 @@ export function SettingsView({
|
|||||||
);
|
);
|
||||||
}, [form, settings]);
|
}, [form, settings]);
|
||||||
|
|
||||||
const runtimeDirty = useMemo(() => {
|
|
||||||
if (!settings) return false;
|
|
||||||
return form.timezone !== settings.agent.timezone;
|
|
||||||
}, [form, settings]);
|
|
||||||
|
|
||||||
const imageGenerationDirty = useMemo(() => {
|
const imageGenerationDirty = useMemo(() => {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
return (
|
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 () => {
|
const saveImageGenerationSettings = async () => {
|
||||||
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
||||||
setImageGenerationSaving(true);
|
setImageGenerationSaving(true);
|
||||||
@ -2276,11 +2226,7 @@ export function SettingsView({
|
|||||||
return (
|
return (
|
||||||
<RuntimeSettings
|
<RuntimeSettings
|
||||||
form={form}
|
form={form}
|
||||||
setForm={setForm}
|
|
||||||
settings={settings}
|
settings={settings}
|
||||||
dirty={runtimeDirty}
|
|
||||||
saving={saving}
|
|
||||||
onSave={saveRuntimeSettings}
|
|
||||||
onRestart={restartViaSettingsSurface}
|
onRestart={restartViaSettingsSurface}
|
||||||
isRestarting={isRestarting || hostEngineApplying}
|
isRestarting={isRestarting || hostEngineApplying}
|
||||||
requiresRestartPending={pendingRestartSections.runtime}
|
requiresRestartPending={pendingRestartSections.runtime}
|
||||||
@ -2902,10 +2848,7 @@ function AppearanceSettings({
|
|||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow title={t("settings.rows.theme")}>
|
||||||
title={t("settings.rows.theme")}
|
|
||||||
description={t("settings.help.theme")}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
@ -2932,10 +2875,7 @@ function AppearanceSettings({
|
|||||||
</button>
|
</button>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow title={t("settings.rows.language")}>
|
||||||
title={t("settings.rows.language")}
|
|
||||||
description={t("settings.help.language")}
|
|
||||||
>
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
@ -2944,10 +2884,7 @@ function AppearanceSettings({
|
|||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.density", "Density")}>
|
||||||
title={tx("settings.rows.density", "Density")}
|
|
||||||
description={tx("settings.help.density", "Stored only in this browser.")}
|
|
||||||
>
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={localPrefs.density}
|
value={localPrefs.density}
|
||||||
options={[
|
options={[
|
||||||
@ -2959,10 +2896,7 @@ function AppearanceSettings({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.activityMode", "Activity detail")}>
|
||||||
title={tx("settings.rows.activityMode", "Activity detail")}
|
|
||||||
description={tx("settings.help.activityMode", "Choose how much agent activity chrome to show by default.")}
|
|
||||||
>
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={localPrefs.activityMode}
|
value={localPrefs.activityMode}
|
||||||
options={[
|
options={[
|
||||||
@ -2974,10 +2908,7 @@ function AppearanceSettings({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.fileEditDisplay", "File edit display")}>
|
||||||
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.")}
|
|
||||||
>
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={localPrefs.fileEditDisplayMode}
|
value={localPrefs.fileEditDisplayMode}
|
||||||
options={[
|
options={[
|
||||||
@ -2993,10 +2924,7 @@ function AppearanceSettings({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.codeWrap", "Code wrapping")}>
|
||||||
title={tx("settings.rows.codeWrap", "Code wrapping")}
|
|
||||||
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
|
|
||||||
>
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
checked={localPrefs.codeWrap}
|
checked={localPrefs.codeWrap}
|
||||||
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, 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")}
|
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
|
||||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
|
||||||
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
|
|
||||||
>
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
checked={localPrefs.brandLogos}
|
checked={localPrefs.brandLogos}
|
||||||
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||||
@ -4854,10 +4779,7 @@ function ImageGenerationSettings({
|
|||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.imageGeneration", "Image generation")}>
|
||||||
title={tx("settings.rows.imageGeneration", "Image generation")}
|
|
||||||
description={tx("settings.help.imageGeneration", "Expose generate_image in chats when a configured image provider is available.")}
|
|
||||||
>
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
checked={form.enabled}
|
checked={form.enabled}
|
||||||
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, 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")}
|
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.imageProvider", "Image provider")}>
|
||||||
title={tx("settings.rows.imageProvider", "Image provider")}
|
|
||||||
description={tx("settings.help.imageProvider", "Choose the registry provider used by generate_image.")}
|
|
||||||
>
|
|
||||||
<ProviderPicker
|
<ProviderPicker
|
||||||
providers={settings.image_generation.providers}
|
providers={settings.image_generation.providers}
|
||||||
value={form.provider}
|
value={form.provider}
|
||||||
@ -4905,10 +4824,7 @@ function ImageGenerationSettings({
|
|||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.imageModel", "Image model")}>
|
||||||
title={tx("settings.rows.imageModel", "Image model")}
|
|
||||||
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
|
|
||||||
>
|
|
||||||
<ModelIdPicker
|
<ModelIdPicker
|
||||||
token={token}
|
token={token}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@ -4928,10 +4844,7 @@ function ImageGenerationSettings({
|
|||||||
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
|
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.defaultAspectRatio", "Default aspect")}>
|
||||||
title={tx("settings.rows.defaultAspectRatio", "Default aspect")}
|
|
||||||
description={tx("settings.help.defaultAspectRatio", "Used when the prompt does not choose an aspect ratio.")}
|
|
||||||
>
|
|
||||||
<ProviderPicker
|
<ProviderPicker
|
||||||
providers={aspectOptions}
|
providers={aspectOptions}
|
||||||
value={form.defaultAspectRatio}
|
value={form.defaultAspectRatio}
|
||||||
@ -4941,10 +4854,7 @@ function ImageGenerationSettings({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.defaultImageSize", "Default size")}>
|
||||||
title={tx("settings.rows.defaultImageSize", "Default size")}
|
|
||||||
description={tx("settings.help.defaultImageSize", "Size hint sent to providers that support it.")}
|
|
||||||
>
|
|
||||||
<ProviderPicker
|
<ProviderPicker
|
||||||
providers={sizeOptions}
|
providers={sizeOptions}
|
||||||
value={form.defaultImageSize}
|
value={form.defaultImageSize}
|
||||||
@ -4954,10 +4864,7 @@ function ImageGenerationSettings({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}>
|
||||||
title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}
|
|
||||||
description={tx("settings.help.maxImagesPerTurn", "Upper bound for one generate_image request.")}
|
|
||||||
>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={form.maxImagesPerTurn}
|
value={form.maxImagesPerTurn}
|
||||||
min={1}
|
min={1}
|
||||||
@ -5038,10 +4945,7 @@ function TranscriptionSettings({
|
|||||||
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.transcriptionProvider", "Provider")}>
|
||||||
title={tx("settings.rows.transcriptionProvider", "Provider")}
|
|
||||||
description={tx("settings.help.transcriptionProvider", "Uses the matching provider credentials from Providers.")}
|
|
||||||
>
|
|
||||||
<ProviderPicker
|
<ProviderPicker
|
||||||
providers={transcription.providers}
|
providers={transcription.providers}
|
||||||
value={form.provider}
|
value={form.provider}
|
||||||
@ -5208,10 +5112,7 @@ function WebSettings({
|
|||||||
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
|
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow title={t("settings.byok.webSearch.provider")}>
|
||||||
title={t("settings.byok.webSearch.provider")}
|
|
||||||
description={t("settings.byok.webSearch.providerHelp")}
|
|
||||||
>
|
|
||||||
<ProviderPicker
|
<ProviderPicker
|
||||||
providers={settings.web_search.providers}
|
providers={settings.web_search.providers}
|
||||||
value={form.provider}
|
value={form.provider}
|
||||||
@ -5222,10 +5123,7 @@ function WebSettings({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
{selectedProvider?.credential === "none" ? (
|
{selectedProvider?.credential === "none" ? (
|
||||||
<SettingsRow
|
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
|
||||||
title={t("settings.byok.webSearch.credentials")}
|
|
||||||
description={t("settings.byok.webSearch.noCredentialHelp")}
|
|
||||||
>
|
|
||||||
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
|
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
) : null}
|
) : null}
|
||||||
@ -5310,10 +5208,7 @@ function WebSettings({
|
|||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
|
||||||
title={tx("settings.rows.maxResults", "Max results")}
|
|
||||||
description={tx("settings.help.maxResults", "Results returned by each web_search call.")}
|
|
||||||
>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={form.maxResults ?? settings.web_search.max_results}
|
value={form.maxResults ?? settings.web_search.max_results}
|
||||||
min={1}
|
min={1}
|
||||||
@ -5321,10 +5216,7 @@ function WebSettings({
|
|||||||
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
|
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
|
||||||
title={tx("settings.rows.timeout", "Timeout")}
|
|
||||||
description={tx("settings.help.timeout", "Seconds before a search provider request times out.")}
|
|
||||||
>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={form.timeout ?? settings.web_search.timeout}
|
value={form.timeout ?? settings.web_search.timeout}
|
||||||
min={1}
|
min={1}
|
||||||
@ -6287,7 +6179,7 @@ function NanobotFeatureInstallDialog({
|
|||||||
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
|
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
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">
|
<DialogHeader className="items-center space-y-0 text-center">
|
||||||
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||||
@ -7074,23 +6966,6 @@ function ChannelsSettings({
|
|||||||
>
|
>
|
||||||
{!showingCompactDetail ? (
|
{!showingCompactDetail ? (
|
||||||
<section className="shrink-0 space-y-4">
|
<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="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<div className="relative min-w-0 flex-1">
|
<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 />
|
<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 ||
|
mcpError ||
|
||||||
(!focusedApp ? cliMessage || mcpMessage : null);
|
(!focusedApp ? cliMessage || mcpMessage : null);
|
||||||
const statusIsError = Boolean(cliError || mcpError);
|
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 (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<section className="space-y-4">
|
<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="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<div className="relative flex-1">
|
<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 />
|
<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({
|
function RuntimeSettings({
|
||||||
form,
|
form,
|
||||||
setForm,
|
|
||||||
settings,
|
settings,
|
||||||
dirty,
|
|
||||||
saving,
|
|
||||||
onSave,
|
|
||||||
onRestart,
|
onRestart,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
requiresRestartPending,
|
requiresRestartPending,
|
||||||
@ -8319,11 +8175,7 @@ function RuntimeSettings({
|
|||||||
onInstallCapability,
|
onInstallCapability,
|
||||||
}: {
|
}: {
|
||||||
form: AgentSettingsDraft;
|
form: AgentSettingsDraft;
|
||||||
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
|
||||||
settings: SettingsPayload;
|
settings: SettingsPayload;
|
||||||
dirty: boolean;
|
|
||||||
saving: boolean;
|
|
||||||
onSave: () => void;
|
|
||||||
onRestart?: () => void;
|
onRestart?: () => void;
|
||||||
isRestarting?: boolean;
|
isRestarting?: boolean;
|
||||||
requiresRestartPending: boolean;
|
requiresRestartPending: boolean;
|
||||||
@ -8419,42 +8271,6 @@ function RuntimeSettings({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<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 ? (
|
{isNativeHost ? (
|
||||||
<section>
|
<section>
|
||||||
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
|
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
|
||||||
@ -8464,9 +8280,7 @@ function RuntimeSettings({
|
|||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={tx("settings.rows.logs", "Logs")}
|
title={tx("settings.rows.logs", "Logs")}
|
||||||
description={
|
description={
|
||||||
hostActionMessage?.target === "logs"
|
hostActionMessage?.target === "logs" ? hostActionMessage.message : undefined
|
||||||
? hostActionMessage.message
|
|
||||||
: tx("settings.help.logs", "Open the native engine log folder.")
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@ -8495,9 +8309,7 @@ function RuntimeSettings({
|
|||||||
description={
|
description={
|
||||||
hostActionMessage?.target === "diagnostics"
|
hostActionMessage?.target === "diagnostics"
|
||||||
? hostActionMessage.message
|
? hostActionMessage.message
|
||||||
: diagnosticsPath
|
: diagnosticsPath || undefined
|
||||||
? diagnosticsPath
|
|
||||||
: tx("settings.help.diagnostics", "Export a small runtime report for support.")
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@ -8542,7 +8354,7 @@ function RuntimeSettings({
|
|||||||
? apiServiceError
|
? apiServiceError
|
||||||
: apiDefaults.running
|
: apiDefaults.running
|
||||||
? apiDefaults.endpoint
|
? 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">
|
<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")}
|
onChange={(value) => setApiHost(value === "network" ? "0.0.0.0" : "127.0.0.1")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.api.port", "Port")}>
|
||||||
title={tx("settings.api.port", "Port")}
|
|
||||||
description={tx("settings.api.portHelp", "The API uses this local port.")}
|
|
||||||
>
|
|
||||||
<NumberInput value={apiPort} min={1} max={65535} onChange={setApiPort} />
|
<NumberInput value={apiPort} min={1} max={65535} onChange={setApiPort} />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
{apiNetworkAccess ? (
|
{apiNetworkAccess ? (
|
||||||
@ -8647,11 +8456,6 @@ function RuntimeSettings({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</SettingsGroup>
|
</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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@ -8661,7 +8465,7 @@ function RuntimeSettings({
|
|||||||
title="Langfuse"
|
title="Langfuse"
|
||||||
description={
|
description={
|
||||||
settings.observability?.configured
|
settings.observability?.configured
|
||||||
? tx("settings.observability.configured", "Tracing credentials are available to nanobot.")
|
? undefined
|
||||||
: tx(
|
: tx(
|
||||||
"settings.observability.environment",
|
"settings.observability.environment",
|
||||||
"Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
|
"Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
|
||||||
@ -8708,10 +8512,15 @@ function RuntimeSettings({
|
|||||||
) : null}
|
) : null}
|
||||||
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
|
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
|
||||||
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_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
|
<SettingsRow
|
||||||
title={t("settings.rows.restart")}
|
title={t("settings.rows.restart")}
|
||||||
description={t("app.system.restartHint")}
|
description={
|
||||||
|
requiresRestartPending
|
||||||
|
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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({
|
function ProviderPicker({
|
||||||
providers,
|
providers,
|
||||||
value,
|
value,
|
||||||
@ -9459,62 +9157,6 @@ function providerVisibilityRank(provider: SettingsPayload["providers"][number]):
|
|||||||
return 200;
|
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(
|
function optionRowsWithCurrent(
|
||||||
options: Array<{ name: string; label: string }>,
|
options: Array<{ name: string; label: string }>,
|
||||||
value: 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({
|
function NumberInput({
|
||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
|
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
|
||||||
import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api";
|
import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api";
|
||||||
@ -36,9 +37,6 @@ import { useClient } from "@/providers/ClientProvider";
|
|||||||
|
|
||||||
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const availableCount = skills.filter(
|
|
||||||
(skill) => skill.enabled !== false && skill.available,
|
|
||||||
).length;
|
|
||||||
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
|
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
|
||||||
const [view, setView] = useState<"installed" | "discover">("installed");
|
const [view, setView] = useState<"installed" | "discover">("installed");
|
||||||
const [installingSkill, setInstallingSkill] = useState("");
|
const [installingSkill, setInstallingSkill] = useState("");
|
||||||
@ -80,51 +78,28 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<SegmentedControl
|
||||||
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
|
value={view}
|
||||||
{t("settings.skills.description", {
|
mode="tabs"
|
||||||
defaultValue:
|
ariaLabel={t("settings.skills.views", { defaultValue: "Skills views" })}
|
||||||
"Review installed skills or discover new capabilities from the skills.sh catalog.",
|
className="w-fit text-[13px]"
|
||||||
})}
|
itemClassName="px-3.5"
|
||||||
</p>
|
options={[
|
||||||
<span className="text-[12px] font-medium text-muted-foreground">
|
{
|
||||||
{t("settings.skills.caption", {
|
value: "installed",
|
||||||
available: availableCount,
|
label: t("settings.skills.installedTab", { defaultValue: "Installed" }),
|
||||||
total: skills.length,
|
},
|
||||||
defaultValue: "{{available}} available · {{total}} total",
|
{
|
||||||
})}
|
value: "discover",
|
||||||
</span>
|
label: t("settings.skills.discoverTab", { defaultValue: "Discover" }),
|
||||||
</section>
|
},
|
||||||
|
]}
|
||||||
<div
|
onChange={setView}
|
||||||
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>
|
|
||||||
|
|
||||||
{view === "installed" ? (
|
{view === "installed" ? (
|
||||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<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]">
|
<div className="relative w-full sm:max-w-[320px]">
|
||||||
<Search
|
<Search
|
||||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
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]"
|
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<SegmentedControl
|
||||||
className={cn(
|
value={installedFilter}
|
||||||
"flex max-w-full items-center gap-1 overflow-x-auto rounded-[10px] bg-muted/65 p-1",
|
className="sm:w-auto"
|
||||||
"scrollbar-thin scrollbar-track-transparent sm:w-auto",
|
itemClassName="px-2.5 text-[11px]"
|
||||||
)}
|
options={([
|
||||||
>
|
|
||||||
{([
|
|
||||||
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
|
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
|
||||||
[
|
[
|
||||||
"enabled",
|
"enabled",
|
||||||
@ -160,28 +133,22 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
|||||||
t("settings.skills.filterDisabled", { defaultValue: "Disabled" }),
|
t("settings.skills.filterDisabled", { defaultValue: "Disabled" }),
|
||||||
disabledCount,
|
disabledCount,
|
||||||
],
|
],
|
||||||
] as const).map(([filter, label, count]) => (
|
] as const).map(([value, label, count]) => ({
|
||||||
<button
|
value,
|
||||||
key={filter}
|
label: (
|
||||||
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>
|
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
|
||||||
</button>
|
</>
|
||||||
))}
|
),
|
||||||
</div>
|
}))}
|
||||||
|
onChange={setInstalledFilter}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{groupedSkills.length ? (
|
{groupedSkills.length ? (
|
||||||
<div className="pb-2">
|
<div className="space-y-5 px-3 pb-3 pt-2 sm:px-4">
|
||||||
{groupedSkills.map((group) => (
|
{groupedSkills.map((group) => (
|
||||||
<section key={group.key}>
|
<section key={group.key} className="space-y-1">
|
||||||
<div className="flex items-center gap-2 bg-muted/20 px-5 py-2.5">
|
<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">
|
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||||
{group.label}
|
{group.label}
|
||||||
</h2>
|
</h2>
|
||||||
@ -189,7 +156,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
|||||||
{group.skills.length}
|
{group.skills.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border/40 px-3 sm:px-4">
|
<div className="space-y-1">
|
||||||
{group.skills.map((skill) => (
|
{group.skills.map((skill) => (
|
||||||
<SkillCatalogRow
|
<SkillCatalogRow
|
||||||
key={`${skill.source}:${skill.name}`}
|
key={`${skill.source}:${skill.name}`}
|
||||||
@ -254,8 +221,8 @@ function SkillCatalogRow({
|
|||||||
onClick={() => onSelect(skill)}
|
onClick={() => onSelect(skill)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left",
|
"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",
|
"transition-colors duration-150",
|
||||||
"hover:bg-muted/70 hover:shadow-[inset_0_0_0_1px_hsl(var(--border)/0.35)]",
|
"hover:bg-muted/70",
|
||||||
"focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
"focus-visible:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
!enabled && "opacity-60",
|
!enabled && "opacity-60",
|
||||||
)}
|
)}
|
||||||
@ -475,17 +442,9 @@ function SkillDetailSheet({
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-5">
|
<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 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">
|
<p className="text-[13px] font-medium text-foreground">
|
||||||
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
|
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
|
||||||
</p>
|
</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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import {
|
import {
|
||||||
fetchMarketplaceSkillTrends,
|
fetchMarketplaceSkillTrends,
|
||||||
fetchTrendingMarketplaceSkills,
|
fetchTrendingMarketplaceSkills,
|
||||||
@ -232,19 +233,12 @@ export function SkillsMarketplace({
|
|||||||
|
|
||||||
{query.trim().length < 2 ? (
|
{query.trim().length < 2 ? (
|
||||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
<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 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">
|
||||||
<div>
|
|
||||||
<h2 className="text-[14px] font-semibold">
|
<h2 className="text-[14px] font-semibold">
|
||||||
{t("settings.skills.marketplaceTrendingTitle", {
|
{t("settings.skills.marketplaceTrendingTitle", {
|
||||||
defaultValue: "Trending by marketplace",
|
defaultValue: "Trending by marketplace",
|
||||||
})}
|
})}
|
||||||
</h2>
|
</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>
|
|
||||||
{provider !== "all" ? (
|
{provider !== "all" ? (
|
||||||
<a
|
<a
|
||||||
href={providerUrl(provider)}
|
href={providerUrl(provider)}
|
||||||
@ -359,32 +353,27 @@ function ProviderFilter({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"];
|
const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"];
|
||||||
return (
|
return (
|
||||||
<div
|
<SegmentedControl
|
||||||
className="flex w-fit items-center gap-0.5 rounded-full bg-settings-surface p-1"
|
value={value}
|
||||||
role="tablist"
|
mode="tabs"
|
||||||
aria-label={t("settings.skills.marketplaceProviderFilter", {
|
ariaLabel={t("settings.skills.marketplaceProviderFilter", {
|
||||||
defaultValue: "Skill source",
|
defaultValue: "Skill source",
|
||||||
})}
|
})}
|
||||||
>
|
className="w-fit bg-settings-surface"
|
||||||
{providers.map((provider) => (
|
itemClassName="inline-flex h-7 items-center gap-1.5"
|
||||||
<button
|
options={providers.map((provider) => ({
|
||||||
key={provider}
|
value: provider,
|
||||||
type="button"
|
label: (
|
||||||
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" ? <ProviderDot provider={provider} /> : null}
|
||||||
{provider === "all"
|
{provider === "all"
|
||||||
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
|
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
|
||||||
: providerLabel(provider)}
|
: providerLabel(provider)}
|
||||||
</button>
|
</>
|
||||||
))}
|
),
|
||||||
</div>
|
}))}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -420,13 +409,13 @@ function MarketplaceSkillGroups({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-5 pb-3 pt-2">
|
||||||
{providers.map((provider) => {
|
{providers.map((provider) => {
|
||||||
const providerSkills = skills.filter((skill) => skill.provider === provider);
|
const providerSkills = skills.filter((skill) => skill.provider === provider);
|
||||||
if (!providerSkills.length) return null;
|
if (!providerSkills.length) return null;
|
||||||
return (
|
return (
|
||||||
<section key={provider} className="border-t border-border/45 first:border-t-0">
|
<section key={provider} className="space-y-1">
|
||||||
<div className="flex items-center justify-between px-5 pb-1 pt-3.5">
|
<div className="flex items-center justify-between px-5 py-1.5">
|
||||||
<ProviderMark provider={provider} />
|
<ProviderMark provider={provider} />
|
||||||
<a
|
<a
|
||||||
href={providerUrl(provider)}
|
href={providerUrl(provider)}
|
||||||
@ -469,7 +458,7 @@ function MarketplaceSkillList({
|
|||||||
onSelect: (skill: MarketplaceSkillSummary) => void;
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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) => (
|
{skills.map((skill) => (
|
||||||
<MarketplaceSkillRow
|
<MarketplaceSkillRow
|
||||||
key={skill.id}
|
key={skill.id}
|
||||||
@ -679,7 +668,7 @@ function TrendSparkline({ values }: { values?: number[] }) {
|
|||||||
|
|
||||||
function TrendingSkeleton() {
|
function TrendingSkeleton() {
|
||||||
return (
|
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) => (
|
{Array.from({ length: 5 }, (_, index) => (
|
||||||
<div key={index} className="flex items-center gap-3 py-4">
|
<div key={index} className="flex items-center gap-3 py-4">
|
||||||
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
|
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
|
||||||
|
|||||||
@ -232,7 +232,7 @@ export function TokenUsageHeatmap({
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="top"
|
side="top"
|
||||||
align="center"
|
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>
|
<span className="block">{label}</span>
|
||||||
{breakdown ? (
|
{breakdown ? (
|
||||||
|
|||||||
@ -136,7 +136,7 @@ export function ChannelLogo({
|
|||||||
if (showBrandLogos && logoUrl) {
|
if (showBrandLogos && logoUrl) {
|
||||||
return (
|
return (
|
||||||
<span
|
<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
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
@ -154,7 +154,7 @@ export function ChannelLogo({
|
|||||||
if (Icon) {
|
if (Icon) {
|
||||||
return (
|
return (
|
||||||
<span
|
<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 }}
|
style={{ color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
@ -165,7 +165,7 @@ export function ChannelLogo({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<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 }}
|
style={{ color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
|
|||||||
@ -177,7 +177,7 @@ export function ChannelInstancesPanel({
|
|||||||
<article
|
<article
|
||||||
key={instance.id}
|
key={instance.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden rounded-[18px] border border-transparent transition-colors",
|
"overflow-hidden rounded-[18px] transition-colors",
|
||||||
expanded
|
expanded
|
||||||
? "bg-background"
|
? "bg-background"
|
||||||
: "bg-background/70 hover:bg-muted",
|
: "bg-background/70 hover:bg-muted",
|
||||||
@ -230,8 +230,8 @@ export function ChannelInstancesPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="border-t border-border/60">
|
<div className="space-y-5 px-4 pb-4">
|
||||||
<section className="px-4 py-4">
|
<section className="pt-4">
|
||||||
<div className="mb-3 flex items-start justify-between gap-3">
|
<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">
|
<p className="min-w-0 flex-1 truncate font-mono text-[11.5px] leading-6 text-muted-foreground">
|
||||||
{customization.renderInstanceSummary?.(instance) ?? instance.id}
|
{customization.renderInstanceSummary?.(instance) ?? instance.id}
|
||||||
@ -256,7 +256,7 @@ export function ChannelInstancesPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{instanceFields.length ? (
|
{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">
|
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{tx("settings.channels.advanced", "Advanced")}
|
{tx("settings.channels.advanced", "Advanced")}
|
||||||
@ -290,8 +290,8 @@ export function ChannelInstancesPanel({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
className="h-8 rounded-full bg-muted/70 px-3 text-[12px] font-semibold hover:bg-muted"
|
||||||
disabled={savingFields}
|
disabled={savingFields}
|
||||||
>
|
>
|
||||||
{savingFields ? (
|
{savingFields ? (
|
||||||
@ -397,7 +397,7 @@ function ChannelInstanceAvatar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<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 }}
|
style={{ color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export function ChannelCatalogRow({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
className={cn(
|
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",
|
selected ? "bg-background" : "hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -194,7 +194,7 @@ export function ChannelSetupPanel({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
disabled={enableBusy}
|
disabled={enableBusy}
|
||||||
onClick={() => onAction("enable", feature.name)}
|
onClick={() => onAction("enable", feature.name)}
|
||||||
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
|
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||||
@ -396,13 +396,13 @@ function ChannelSetupSurface({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="mt-5 overflow-hidden rounded-[16px] bg-background/55"
|
className="mt-5 space-y-5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (mode === "credentials") void saveCredentialSettings();
|
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="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="text-[13px] font-semibold text-foreground">
|
<div className="text-[13px] font-semibold text-foreground">
|
||||||
{tx("settings.channels.requiredSetup", "Required setup")}
|
{tx("settings.channels.requiredSetup", "Required setup")}
|
||||||
@ -444,8 +444,8 @@ function ChannelSetupSurface({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setNotice(
|
setNotice(
|
||||||
tx(
|
tx(
|
||||||
@ -461,7 +461,7 @@ function ChannelSetupSurface({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||||
onClick={copyCommand}
|
onClick={copyCommand}
|
||||||
>
|
>
|
||||||
@ -498,8 +498,8 @@ function ChannelSetupSurface({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving || validating ? (
|
{saving || validating ? (
|
||||||
@ -527,7 +527,7 @@ function ChannelSetupSurface({
|
|||||||
{notice ? (
|
{notice ? (
|
||||||
<div
|
<div
|
||||||
role="status"
|
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}
|
{notice}
|
||||||
</div>
|
</div>
|
||||||
@ -540,7 +540,7 @@ function ChannelSetupSurface({
|
|||||||
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
|
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
|
||||||
|
|
||||||
{hasAdvanced ? (
|
{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">
|
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{tx("settings.channels.advanced", "Advanced")}
|
{tx("settings.channels.advanced", "Advanced")}
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export function ChannelGuideLink({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(
|
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
|
compact
|
||||||
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
|
? "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]",
|
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
|
||||||
@ -65,7 +65,7 @@ export function ChannelGuideLink({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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]",
|
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
|
||||||
)}
|
)}
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
@ -135,10 +135,10 @@ export function ChannelOfficialLink({
|
|||||||
href={setup.officialUrl}
|
href={setup.officialUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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
|
<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 }}
|
style={{ color }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
@ -182,8 +182,8 @@ export function ChannelSetupActions({
|
|||||||
key={action.id}
|
key={action.id}
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold hover:bg-background"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (action.copyText) {
|
if (action.copyText) {
|
||||||
void copyTextToClipboard(action.copyText).then((ok) =>
|
void copyTextToClipboard(action.copyText).then((ok) =>
|
||||||
@ -246,8 +246,7 @@ export function ChannelProviderPresets({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
||||||
selected === preset.id
|
selected === preset.id && "bg-background text-foreground",
|
||||||
&& "bg-background text-foreground ring-1 ring-inset ring-border/45",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
@ -307,7 +306,7 @@ export function ChannelValidationChecks({ validation }: { validation: ChannelVal
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!validation.checks.length) return null;
|
if (!validation.checks.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/60 px-4 py-4">
|
<div>
|
||||||
<div className="mb-2 text-[12px] font-semibold text-foreground">
|
<div className="mb-2 text-[12px] font-semibold text-foreground">
|
||||||
{t("settings.channels.connectionChecks")}
|
{t("settings.channels.connectionChecks")}
|
||||||
</div>
|
</div>
|
||||||
@ -353,7 +352,7 @@ export function ChannelSetupSteps({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
return (
|
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="mb-2 flex items-center justify-between gap-3">
|
||||||
<div className="text-[12px] font-semibold text-foreground">
|
<div className="text-[12px] font-semibold text-foreground">
|
||||||
{tx("settings.channels.setupSteps", "Next steps")}
|
{tx("settings.channels.setupSteps", "Next steps")}
|
||||||
@ -371,7 +370,7 @@ export function ChannelSetupSteps({
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
{tryIt ? (
|
{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">
|
<span className="font-medium text-foreground">
|
||||||
{tx("settings.channels.tryIt", "Try it")}
|
{tx("settings.channels.tryIt", "Try it")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import { createPortal } from "react-dom";
|
|||||||
import { MessageCircleMore } from "lucide-react";
|
import { MessageCircleMore } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const MAX_QUOTED_CONTEXT_CHARS = 4_000;
|
const MAX_QUOTED_CONTEXT_CHARS = 4_000;
|
||||||
|
|
||||||
interface SelectionActionState {
|
interface SelectionActionState {
|
||||||
@ -142,7 +145,10 @@ export function AssistantSelectionAction({
|
|||||||
ref={actionRef}
|
ref={actionRef}
|
||||||
type="button"
|
type="button"
|
||||||
data-selection-follow-up="true"
|
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={{
|
style={{
|
||||||
left: action.left,
|
left: action.left,
|
||||||
top: action.top,
|
top: action.top,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText } from "@/components/MarkdownText";
|
||||||
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
@ -203,8 +204,7 @@ export function PromptRail({
|
|||||||
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
|
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
|
||||||
className={cn(
|
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",
|
"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",
|
floatingSurfaceElevationClassName,
|
||||||
"dark:shadow-[0_18px_45px_rgba(0,0,0,0.45)]",
|
|
||||||
"transition-[opacity,transform] duration-150",
|
"transition-[opacity,transform] duration-150",
|
||||||
previewVisible
|
previewVisible
|
||||||
? "translate-x-0 scale-100 opacity-100"
|
? "translate-x-0 scale-100 opacity-100"
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
floatingItemClassName,
|
floatingItemClassName,
|
||||||
|
floatingSurfaceElevationClassName,
|
||||||
floatingSurfaceVisualClassName,
|
floatingSurfaceVisualClassName,
|
||||||
} from "@/components/ui/floating-surface";
|
} from "@/components/ui/floating-surface";
|
||||||
import {
|
import {
|
||||||
@ -805,8 +806,8 @@ function RunElapsedStrip({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute bottom-[calc(100%+8px)] left-3 right-3 z-[50] flex max-w-none flex-col overflow-hidden",
|
"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)]",
|
"rounded-2xl",
|
||||||
"backdrop-blur-sm dark:border-white/[0.1] dark:shadow-[0_16px_48px_rgba(0,0,0,0.45)]",
|
floatingSurfaceElevationClassName,
|
||||||
)}
|
)}
|
||||||
style={{ maxHeight: `${Math.round(panelMaxPx)}px` }}
|
style={{ maxHeight: `${Math.round(panelMaxPx)}px` }}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
modalOverlayClassName,
|
||||||
|
modalSurfaceClassName,
|
||||||
|
} from "@/components/ui/floating-surface";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root;
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
@ -14,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -32,7 +36,8 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -3,6 +3,10 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
modalOverlayClassName,
|
||||||
|
modalSurfaceClassName,
|
||||||
|
} from "@/components/ui/floating-surface";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root;
|
||||||
@ -15,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -40,7 +44,8 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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 =
|
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 =
|
export const floatingSurfaceClassName =
|
||||||
`${floatingSurfaceVisualClassName} z-50 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;
|
`${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
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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=open]:animate-in data-[state=closed]:animate-out",
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className,
|
className,
|
||||||
@ -27,24 +27,24 @@ const SheetOverlay = React.forwardRef<
|
|||||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
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: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
top: cn(
|
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",
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
),
|
),
|
||||||
bottom: cn(
|
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",
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
),
|
),
|
||||||
left: cn(
|
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",
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
||||||
),
|
),
|
||||||
right: cn(
|
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",
|
"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 React from "react";
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider;
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
@ -16,7 +17,8 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -509,6 +509,9 @@ describe("App layout", () => {
|
|||||||
expect(screen.getByText("cron")).toBeInTheDocument();
|
expect(screen.getByText("cron")).toBeInTheDocument();
|
||||||
expect(screen.getByText("github")).toBeInTheDocument();
|
expect(screen.getByText("github")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Needs setup")).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.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||||
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
|
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
|
||||||
@ -534,6 +537,11 @@ describe("App layout", () => {
|
|||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Setup required")).toBeInTheDocument();
|
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.getByText("brew install gh")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();
|
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument();
|
expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument();
|
||||||
@ -737,6 +745,9 @@ describe("App layout", () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Trending by marketplace" }),
|
await screen.findByRole("heading", { name: "Trending by marketplace" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Each marketplace keeps its own ranking and install metrics."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("find-skills")).toBeInTheDocument();
|
expect(screen.getByText("find-skills")).toBeInTheDocument();
|
||||||
expect(screen.getByText("ima-skills")).toBeInTheDocument();
|
expect(screen.getByText("ima-skills")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("SkillHub")).toHaveLength(2);
|
expect(screen.getAllByText("SkillHub")).toHaveLength(2);
|
||||||
@ -1994,6 +2005,11 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Appearance" }));
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Appearance" }));
|
||||||
expect(screen.getByText("Brand logos")).toBeInTheDocument();
|
expect(screen.getByText("Brand logos")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("switch", { name: "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();
|
expect(within(settingsNav).getByRole("button", { name: "Settings: Appearance" })).toBeInTheDocument();
|
||||||
fireEvent.pointerDown(within(settingsNav).getByRole("button", { name: "Settings: Appearance" }));
|
fireEvent.pointerDown(within(settingsNav).getByRole("button", { name: "Settings: Appearance" }));
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Models" }));
|
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.getByRole("button", { name: "openai/gpt-5.4-image-2" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Save directory")).toBeInTheDocument();
|
expect(screen.getByText("Save directory")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
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" }));
|
fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" }));
|
||||||
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
expect(screen.getByText("Search provider")).toBeInTheDocument();
|
||||||
@ -2086,6 +2110,12 @@ describe("App layout", () => {
|
|||||||
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("provider-picker-logo-brave")).toBeInTheDocument();
|
expect(screen.getByTestId("provider-picker-logo-brave")).toBeInTheDocument();
|
||||||
expect(screen.getByText("BSAo••••ew20")).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.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
||||||
target: { value: "unsaved-brave-key" },
|
target: { value: "unsaved-brave-key" },
|
||||||
@ -2098,8 +2128,14 @@ describe("App layout", () => {
|
|||||||
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
|
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
|
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.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 name")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Bot icon")).not.toBeInTheDocument();
|
expect(screen.queryByText("Bot icon")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Tool hint length")).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("Dream")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
|
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Default workspace")).toBeInTheDocument();
|
expect(screen.getByText("Default workspace")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
expect(screen.getByText("UTC")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "UTC" }));
|
expect(screen.queryByPlaceholderText("Search timezone")).not.toBeInTheDocument();
|
||||||
const timezoneSearch = await screen.findByPlaceholderText("Search timezone");
|
expect(screen.queryByRole("listbox", { name: "Select timezone" })).not.toBeInTheDocument();
|
||||||
expect(timezoneSearch).toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "UTC" })).not.toBeInTheDocument();
|
||||||
fireEvent.change(timezoneSearch, {
|
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
|
||||||
target: { value: "Shanghai" },
|
expect(
|
||||||
});
|
screen.queryByText("Connect SDKs and agents through a local /v1 endpoint."),
|
||||||
await user.click(screen.getByRole("option", { name: /Asia\/Shanghai/ }));
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
|
expect(screen.queryByText("The API uses this local port.")).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores the settings section from the URL hash after a page reload", async () => {
|
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");
|
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 () => {
|
it("falls back to Overview for the retired Files settings URL", async () => {
|
||||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||||
window.history.replaceState(null, "", "/#/settings?section=files");
|
window.history.replaceState(null, "", "/#/settings?section=files");
|
||||||
@ -2211,6 +2281,7 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(appsButton);
|
fireEvent.click(appsButton);
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
|
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.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||||
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
|
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
|
||||||
|
|||||||
@ -113,6 +113,38 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
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 () => {
|
it("renders failed delivery details on focus without persistent accepted chrome", async () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "u-delivery",
|
id: "u-delivery",
|
||||||
|
|||||||
@ -610,14 +610,16 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
|
|
||||||
renderSettingsView({ initialSection: "apps" });
|
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: "Ready" })).toHaveAttribute("aria-pressed", "false");
|
||||||
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
|
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
|
||||||
expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Api")).not.toBeInTheDocument();
|
expect(screen.queryByText("Api")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("AnyGen")).toBeInTheDocument();
|
expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("0 ready")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows nanobot optional features and enables one", async () => {
|
it("shows nanobot optional features and enables one", async () => {
|
||||||
@ -787,7 +789,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
renderSettingsView({ initialSection: "channels" });
|
renderSettingsView({ initialSection: "channels" });
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
|
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.getAllByText("Failed").length).toBeGreaterThan(0);
|
||||||
expect(screen.queryByText("Enabled, support needs install")).not.toBeInTheDocument();
|
expect(screen.queryByText("Enabled, support needs install")).not.toBeInTheDocument();
|
||||||
|
|
||||||
@ -841,7 +843,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
renderSettingsView({ initialSection: "channels" });
|
renderSettingsView({ initialSection: "channels" });
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "View Matrix settings" })).toBeInTheDocument();
|
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.getAllByText("Failed").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(runtimeError)).toBeInTheDocument();
|
expect(screen.getByText(runtimeError)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute(
|
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute(
|
||||||
@ -1353,7 +1355,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
renderSettingsView({ initialSection: "channels" });
|
renderSettingsView({ initialSection: "channels" });
|
||||||
|
|
||||||
await screen.findByText("No assistant connected");
|
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.getAllByText("Failed").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(runtimeError)).toBeInTheDocument();
|
expect(screen.getByText(runtimeError)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("switch", { name: "test assistant" })).toHaveAttribute(
|
expect(screen.getByRole("switch", { name: "test assistant" })).toHaveAttribute(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user