mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-23 05:31:23 +00:00
fix(dashboard): UI polish — modals, layout, consistency, test fixes
Dashboard UX polish pass — consolidates create forms into modals triggered from the page header, fixes layout inconsistencies, adds scroll-to navigation for the Keys page, and aligns the TokenBar with the design system. Changes: - App.tsx: add padding to sidebar header - resolve-page-title.ts: add missing routes, better fallback title - en.ts: fix nav labels (Profiles was 'profiles : multi agents') - ModelsPage: two-col layout, auxiliary tasks modal, TokenBar redesign - ProfilesPage: create button in header, form in modal, Checkbox component - CronPage: create button in header, form in modal - EnvPage: scroll-to sub-nav in header, fix text overflow Modal and dialog standardization: - Replace all native confirm()/window.confirm() with ConfirmDialog (OAuthProvidersCard, PluginsPage, ModelsPage, ConfigPage) - Add useModalBehavior hook (Escape-to-close, scroll lock, focus restore) - Apply hook to ProfilesPage, CronPage, AuxiliaryTasksModal Component fixes (from PR review): - Checkbox: fix controlled/uncontrolled mismatch, add focus-visible ring - TokenBar: add rounded-full to legend dots, remove dead code CI/test fixes: - Fix TS unused imports (noUnusedLocals), type-narrow PickerTarget union - Add windows-footgun suppression on platform-guarded os.killpg - Fix 19 stale unit tests + 9 e2e tests broken by recent main changes - Restore minimal example-dashboard plugin for plugin auth test
This commit is contained in:
parent
dd0923bb89
commit
fc3fd6bb6b
27 changed files with 788 additions and 295 deletions
14
plugins/example-dashboard/dashboard/manifest.json
Normal file
14
plugins/example-dashboard/dashboard/manifest.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "example",
|
||||||
|
"label": "Example",
|
||||||
|
"description": "Example dashboard plugin — used by test suite for auth coverage",
|
||||||
|
"icon": "Sparkles",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"tab": {
|
||||||
|
"path": "/example",
|
||||||
|
"position": "after:skills"
|
||||||
|
},
|
||||||
|
"slots": [],
|
||||||
|
"entry": "dist/index.js",
|
||||||
|
"api": "plugin_api.py"
|
||||||
|
}
|
||||||
17
plugins/example-dashboard/dashboard/plugin_api.py
Normal file
17
plugins/example-dashboard/dashboard/plugin_api.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Example dashboard plugin — backend API routes.
|
||||||
|
|
||||||
|
Mounted at /api/plugins/example/ by the dashboard plugin system.
|
||||||
|
|
||||||
|
This minimal plugin exists so the test suite has a stable, side-effect-free
|
||||||
|
GET endpoint to verify that plugin API routes work with auth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hello")
|
||||||
|
async def hello():
|
||||||
|
"""Simple greeting endpoint to demonstrate plugin API routes."""
|
||||||
|
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
|
||||||
|
|
@ -660,6 +660,7 @@ class TestAuxiliaryPoolAwareness:
|
||||||
with (
|
with (
|
||||||
patch("agent.auxiliary_client.load_pool", return_value=_Pool()),
|
patch("agent.auxiliary_client.load_pool", return_value=_Pool()),
|
||||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||||
|
patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None),
|
||||||
):
|
):
|
||||||
from agent.auxiliary_client import _try_nous
|
from agent.auxiliary_client import _try_nous
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,9 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate
|
||||||
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
|
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
|
||||||
runner._emit_gateway_run_progress = AsyncMock()
|
runner._emit_gateway_run_progress = AsyncMock()
|
||||||
|
|
||||||
|
# Disable destructive slash confirm gate so /new executes immediately
|
||||||
|
runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}}
|
||||||
|
|
||||||
runner.pairing_store = MagicMock()
|
runner.pairing_store = MagicMock()
|
||||||
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
|
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
|
||||||
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
|
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,8 @@ class TestStreamingConfig:
|
||||||
"fresh_final_after_seconds": "oops",
|
"fresh_final_after_seconds": "oops",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert restored.edit_interval == 1.0
|
assert restored.edit_interval == 0.8
|
||||||
assert restored.buffer_threshold == 40
|
assert restored.buffer_threshold == 24
|
||||||
assert restored.fresh_final_after_seconds == 60.0
|
assert restored.fresh_final_after_seconds == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ only renders as a voice bubble when explicitly flagged) and via
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -106,6 +106,16 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_
|
||||||
adapter.send_document.assert_not_awaited()
|
adapter.send_document.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_runner(thread_meta):
|
||||||
|
"""Build a fake GatewayRunner-like object with the helper methods needed by
|
||||||
|
_deliver_media_from_response."""
|
||||||
|
runner = SimpleNamespace(
|
||||||
|
_thread_metadata_for_source=lambda source, anchor=None: thread_meta,
|
||||||
|
_reply_anchor_for_event=lambda event: None,
|
||||||
|
)
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender():
|
async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender():
|
||||||
event = _event(thread_id="topic-1")
|
event = _event(thread_id="topic-1")
|
||||||
|
|
@ -121,7 +131,7 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen
|
||||||
)
|
)
|
||||||
|
|
||||||
await GatewayRunner._deliver_media_from_response(
|
await GatewayRunner._deliver_media_from_response(
|
||||||
object(),
|
_fake_runner({"thread_id": "topic-1"}),
|
||||||
"MEDIA:/tmp/speech.flac",
|
"MEDIA:/tmp/speech.flac",
|
||||||
event,
|
event,
|
||||||
adapter,
|
adapter,
|
||||||
|
|
@ -150,7 +160,7 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc
|
||||||
)
|
)
|
||||||
|
|
||||||
await GatewayRunner._deliver_media_from_response(
|
await GatewayRunner._deliver_media_from_response(
|
||||||
object(),
|
_fake_runner({"thread_id": "topic-1"}),
|
||||||
"MEDIA:/tmp/speech.ogg",
|
"MEDIA:/tmp/speech.ogg",
|
||||||
event,
|
event,
|
||||||
adapter,
|
adapter,
|
||||||
|
|
@ -181,7 +191,7 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(
|
||||||
)
|
)
|
||||||
|
|
||||||
await GatewayRunner._deliver_media_from_response(
|
await GatewayRunner._deliver_media_from_response(
|
||||||
object(),
|
_fake_runner({"thread_id": "topic-1"}),
|
||||||
"MEDIA:/tmp/speech.mp3",
|
"MEDIA:/tmp/speech.mp3",
|
||||||
event,
|
event,
|
||||||
adapter,
|
adapter,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ def _make_runner(hermes_home=None):
|
||||||
runner._pending_messages = {}
|
runner._pending_messages = {}
|
||||||
runner._pending_approvals = {}
|
runner._pending_approvals = {}
|
||||||
runner._failed_platforms = {}
|
runner._failed_platforms = {}
|
||||||
|
# config is accessed by _check_slash_access and quick_commands lookup;
|
||||||
|
# None makes policy_for_source return a disabled (allow-all) policy.
|
||||||
|
runner.config = None
|
||||||
# Bypass the destructive-slash confirm gate — this test exercises
|
# Bypass the destructive-slash confirm gate — this test exercises
|
||||||
# update-prompt interception, not the confirm prompt.
|
# update-prompt interception, not the confirm prompt.
|
||||||
runner._read_user_config = lambda: {
|
runner._read_user_config = lambda: {
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ class TestVerboseCommand:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
|
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
|
||||||
"""When tool_progress is not in config, defaults to 'all' then cycles to verbose."""
|
"""When tool_progress is not in config, defaults to platform default then cycles."""
|
||||||
hermes_home = tmp_path / "hermes"
|
hermes_home = tmp_path / "hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
config_path = hermes_home / "config.yaml"
|
config_path = hermes_home / "config.yaml"
|
||||||
|
|
@ -143,17 +143,17 @@ class TestVerboseCommand:
|
||||||
runner = _make_runner()
|
runner = _make_runner()
|
||||||
result = await runner._handle_verbose_command(_make_event())
|
result = await runner._handle_verbose_command(_make_event())
|
||||||
|
|
||||||
# Telegram default is "all" (high tier) → cycles to verbose
|
# Telegram platform default is "new" → cycles to "all"
|
||||||
assert "VERBOSE" in result
|
assert "ALL" in result
|
||||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
|
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_per_platform_isolation(self, tmp_path, monkeypatch):
|
async def test_per_platform_isolation(self, tmp_path, monkeypatch):
|
||||||
"""Cycling /verbose on Telegram doesn't change Slack's setting.
|
"""Cycling /verbose on Telegram doesn't change Slack's setting.
|
||||||
|
|
||||||
Without a global tool_progress, each platform uses its built-in
|
Without a global tool_progress, each platform uses its built-in
|
||||||
default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default).
|
default: Telegram = 'new' (overridden high tier), Slack = 'off' (quiet Slack default).
|
||||||
"""
|
"""
|
||||||
hermes_home = tmp_path / "hermes"
|
hermes_home = tmp_path / "hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
|
|
@ -178,8 +178,8 @@ class TestVerboseCommand:
|
||||||
|
|
||||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
platforms = saved["display"]["platforms"]
|
platforms = saved["display"]["platforms"]
|
||||||
# Telegram: all -> verbose (high tier default = all)
|
# Telegram: new -> all (platform default = new)
|
||||||
assert platforms["telegram"]["tool_progress"] == "verbose"
|
assert platforms["telegram"]["tool_progress"] == "all"
|
||||||
# Slack: off -> new (first /verbose cycle from quiet default)
|
# Slack: off -> new (first /verbose cycle from quiet default)
|
||||||
assert platforms["slack"]["tool_progress"] == "new"
|
assert platforms["slack"]["tool_progress"] == "new"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def test_profiles_nav_label_uses_short_multi_agents_copy():
|
def test_profiles_nav_label_uses_short_copy():
|
||||||
en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts"
|
en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts"
|
||||||
|
|
||||||
content = en_i18n.read_text(encoding="utf-8")
|
content = en_i18n.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert 'profiles: "profiles : multi agents"' in content
|
# Nav label should be the clean short form, not the old verbose string
|
||||||
assert "Profiles: Running Multiple Agents" not in content
|
assert 'profiles: "Profiles"' in content
|
||||||
|
assert "profiles : multi agents" not in content
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ rather than leaving zombie processes or telling users to manually restart
|
||||||
when launchd will auto-respawn.
|
when launchd will auto-respawn.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
@ -1068,13 +1069,18 @@ class TestFindGatewayPidsExclude:
|
||||||
|
|
||||||
def test_excludes_specified_pids(self, monkeypatch):
|
def test_excludes_specified_pids(self, monkeypatch):
|
||||||
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
|
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
|
||||||
|
# Bypass /proc scan so the subprocess (ps) fallback is used
|
||||||
|
_real_isdir = os.path.isdir
|
||||||
|
monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
|
||||||
|
monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set())
|
||||||
|
monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
return subprocess.CompletedProcess(
|
return subprocess.CompletedProcess(
|
||||||
cmd, 0,
|
cmd, 0,
|
||||||
stdout=(
|
stdout=(
|
||||||
"user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
|
"100 python gateway/run.py\n"
|
||||||
"user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
|
"200 python gateway/run.py\n"
|
||||||
),
|
),
|
||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
@ -1082,19 +1088,24 @@ class TestFindGatewayPidsExclude:
|
||||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||||
monkeypatch.setattr("os.getpid", lambda: 999)
|
monkeypatch.setattr("os.getpid", lambda: 999)
|
||||||
|
|
||||||
pids = gateway_cli.find_gateway_pids(exclude_pids={100})
|
pids = gateway_cli.find_gateway_pids(exclude_pids={100}, all_profiles=True)
|
||||||
assert 100 not in pids
|
assert 100 not in pids
|
||||||
assert 200 in pids
|
assert 200 in pids
|
||||||
|
|
||||||
def test_no_exclude_returns_all(self, monkeypatch):
|
def test_no_exclude_returns_all(self, monkeypatch):
|
||||||
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
|
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
|
||||||
|
# Bypass /proc scan so the subprocess (ps) fallback is used
|
||||||
|
_real_isdir = os.path.isdir
|
||||||
|
monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
|
||||||
|
monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set())
|
||||||
|
monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
return subprocess.CompletedProcess(
|
return subprocess.CompletedProcess(
|
||||||
cmd, 0,
|
cmd, 0,
|
||||||
stdout=(
|
stdout=(
|
||||||
"user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
|
"100 python gateway/run.py\n"
|
||||||
"user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n"
|
"200 python gateway/run.py\n"
|
||||||
),
|
),
|
||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
@ -1102,7 +1113,7 @@ class TestFindGatewayPidsExclude:
|
||||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||||
monkeypatch.setattr("os.getpid", lambda: 999)
|
monkeypatch.setattr("os.getpid", lambda: 999)
|
||||||
|
|
||||||
pids = gateway_cli.find_gateway_pids()
|
pids = gateway_cli.find_gateway_pids(all_profiles=True)
|
||||||
assert 100 in pids
|
assert 100 in pids
|
||||||
assert 200 in pids
|
assert 200 in pids
|
||||||
|
|
||||||
|
|
@ -1111,6 +1122,10 @@ class TestFindGatewayPidsExclude:
|
||||||
profile_dir.mkdir(parents=True)
|
profile_dir.mkdir(parents=True)
|
||||||
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
|
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
|
||||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
|
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
|
||||||
|
# Bypass /proc scan so the subprocess (ps) fallback is used
|
||||||
|
_real_isdir = os.path.isdir
|
||||||
|
monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p))
|
||||||
|
monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999})
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
return subprocess.CompletedProcess(
|
return subprocess.CompletedProcess(
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ class TestClientCacheBoundedGrowth:
|
||||||
_get_cached_client,
|
_get_cached_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
key = ("test_replace", True, "", "", "", (), False)
|
key = ("test_replace", True, "", "", "", (), False, "")
|
||||||
|
|
||||||
# Simulate a stale entry from a closed loop
|
# Simulate a stale entry from a closed loop
|
||||||
old_loop = asyncio.new_event_loop()
|
old_loop = asyncio.new_event_loop()
|
||||||
|
|
|
||||||
|
|
@ -945,7 +945,8 @@ class TestAuxiliaryClientProviderPriority:
|
||||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||||
from agent.auxiliary_client import get_text_auxiliary_client
|
from agent.auxiliary_client import get_text_auxiliary_client
|
||||||
with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \
|
with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \
|
||||||
patch("agent.auxiliary_client.OpenAI") as mock:
|
patch("agent.auxiliary_client.OpenAI") as mock, \
|
||||||
|
patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None):
|
||||||
client, model = get_text_auxiliary_client()
|
client, model = get_text_auxiliary_client()
|
||||||
assert model == "google/gemini-3-flash-preview"
|
assert model == "google/gemini-3-flash-preview"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ class TestEphemeralMaxOutputTokens:
|
||||||
agent.reasoning_config = None
|
agent.reasoning_config = None
|
||||||
agent._is_anthropic_oauth = False
|
agent._is_anthropic_oauth = False
|
||||||
agent._ephemeral_max_output_tokens = None
|
agent._ephemeral_max_output_tokens = None
|
||||||
|
agent._use_long_lived_prefix_cache = False
|
||||||
|
|
||||||
compressor = MagicMock()
|
compressor = MagicMock()
|
||||||
compressor.context_length = 200_000
|
compressor.context_length = 200_000
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,14 @@ class TestHandleVisionAnalyzeFastPath:
|
||||||
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
|
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
|
||||||
set_runtime_main("openrouter", "anthropic/claude-opus-4.6")
|
set_runtime_main("openrouter", "anthropic/claude-opus-4.6")
|
||||||
try:
|
try:
|
||||||
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
|
# Mock decide_image_input_mode to always return "native" so the
|
||||||
result = asyncio.get_event_loop().run_until_complete(coro)
|
# fast path fires regardless of model-catalog state in CI.
|
||||||
|
with patch(
|
||||||
|
"agent.image_routing.decide_image_input_mode",
|
||||||
|
return_value="native",
|
||||||
|
):
|
||||||
|
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(coro)
|
||||||
finally:
|
finally:
|
||||||
clear_runtime_main()
|
clear_runtime_main()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -585,7 +585,7 @@ class ProcessRegistry:
|
||||||
try:
|
try:
|
||||||
if not _IS_WINDOWS:
|
if not _IS_WINDOWS:
|
||||||
try:
|
try:
|
||||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # windows-footgun: ok — guarded by _IS_WINDOWS check above
|
||||||
except (ProcessLookupError, PermissionError, OSError):
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
proc.kill()
|
proc.kill()
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -473,7 +473,7 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-14 shrink-0 items-center justify-between gap-2",
|
"flex h-14 shrink-0 items-center justify-between gap-2 px-4",
|
||||||
"border-b border-current/20",
|
"border-b border-current/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
|
||||||
|
|
@ -55,6 +56,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
||||||
|
const [disconnectTarget, setDisconnectTarget] =
|
||||||
|
useState<OAuthProvider | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
|
|
@ -74,10 +77,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||||
if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusyId(provider.id);
|
setBusyId(provider.id);
|
||||||
|
setDisconnectTarget(null);
|
||||||
try {
|
try {
|
||||||
await api.disconnectOAuthProvider(provider.id);
|
await api.disconnectOAuthProvider(provider.id);
|
||||||
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
|
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
|
||||||
|
|
@ -236,7 +237,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => handleDisconnect(p)}
|
onClick={() => setDisconnectTarget(p)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
prefix={isBusy ? <Spinner /> : <LogOut />}
|
prefix={isBusy ? <Spinner /> : <LogOut />}
|
||||||
>
|
>
|
||||||
|
|
@ -266,6 +267,17 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||||
onError={(msg) => onError?.(msg)}
|
onError={(msg) => onError?.(msg)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={disconnectTarget !== null}
|
||||||
|
onCancel={() => setDisconnectTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (disconnectTarget) void handleDisconnect(disconnectTarget);
|
||||||
|
}}
|
||||||
|
title={`${t.oauth.disconnect} ${disconnectTarget?.name ?? ""}?`}
|
||||||
|
description={`This will remove the stored OAuth tokens for ${disconnectTarget?.name ?? "this provider"}. You will need to re-authenticate to use it again.`}
|
||||||
|
destructive
|
||||||
|
confirmLabel={t.oauth.disconnect}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
web/src/components/ui/checkbox.tsx
Normal file
61
web/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
interface CheckboxProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
checked,
|
||||||
|
defaultChecked,
|
||||||
|
...props
|
||||||
|
}: CheckboxProps) {
|
||||||
|
// Support both controlled (checked prop) and uncontrolled (defaultChecked) usage.
|
||||||
|
// For visual rendering, prefer `checked` if provided; otherwise fall back to defaultChecked.
|
||||||
|
const isChecked = checked ?? defaultChecked ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2.5 cursor-pointer select-none",
|
||||||
|
props.disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-4 w-4 shrink-0 items-center justify-center transition-all",
|
||||||
|
"border bg-background/40",
|
||||||
|
// Focus-visible ring for keyboard accessibility
|
||||||
|
"group-has-[:focus-visible]:ring-2 group-has-[:focus-visible]:ring-ring group-has-[:focus-visible]:ring-offset-1",
|
||||||
|
isChecked
|
||||||
|
? "border-foreground bg-foreground/20"
|
||||||
|
: "border-border group-hover:border-foreground/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 transition-opacity",
|
||||||
|
isChecked
|
||||||
|
? "text-foreground opacity-100"
|
||||||
|
: "text-foreground opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
checked={checked}
|
||||||
|
defaultChecked={checked === undefined ? defaultChecked : undefined}
|
||||||
|
className="sr-only"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{label && <span className="text-sm">{label}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
web/src/hooks/useModalBehavior.ts
Normal file
44
web/src/hooks/useModalBehavior.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that adds standard modal behaviors when `open` is true:
|
||||||
|
* - Escape key calls `onClose`
|
||||||
|
* - Body scroll is locked
|
||||||
|
* - Focus is restored to the previously focused element on close
|
||||||
|
*
|
||||||
|
* Returns a ref to attach to the modal container (for optional future focus trapping).
|
||||||
|
*/
|
||||||
|
export function useModalBehavior({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const prevActive = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
prevActive?.focus?.();
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
return containerRef;
|
||||||
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ export const en: Translations = {
|
||||||
keys: "Keys",
|
keys: "Keys",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
models: "Models",
|
models: "Models",
|
||||||
profiles: "profiles : multi agents",
|
profiles: "Profiles",
|
||||||
plugins: "Plugins",
|
plugins: "Plugins",
|
||||||
sessions: "Sessions",
|
sessions: "Sessions",
|
||||||
skills: "Skills",
|
skills: "Skills",
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
|
||||||
"/chat": "chat",
|
"/chat": "chat",
|
||||||
"/sessions": "sessions",
|
"/sessions": "sessions",
|
||||||
"/analytics": "analytics",
|
"/analytics": "analytics",
|
||||||
|
"/models": "models",
|
||||||
"/logs": "logs",
|
"/logs": "logs",
|
||||||
"/cron": "cron",
|
"/cron": "cron",
|
||||||
"/skills": "skills",
|
"/skills": "skills",
|
||||||
"/plugins": "plugins",
|
"/plugins": "plugins",
|
||||||
|
"/profiles": "profiles",
|
||||||
"/config": "config",
|
"/config": "config",
|
||||||
"/env": "keys",
|
"/env": "keys",
|
||||||
"/docs": "documentation",
|
"/docs": "documentation",
|
||||||
|
|
@ -30,5 +32,10 @@ export function resolvePageTitle(
|
||||||
if (key) {
|
if (key) {
|
||||||
return t.app.nav[key];
|
return t.app.nav[key];
|
||||||
}
|
}
|
||||||
|
// Derive title from pathname: "/profiles" → "Profiles"
|
||||||
|
const segment = normalized.slice(1);
|
||||||
|
if (segment) {
|
||||||
|
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||||
|
}
|
||||||
return t.app.webUi;
|
return t.app.webUi;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import { Button } from "@nous-research/ui/ui/components/button";
|
||||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
|
@ -118,6 +119,7 @@ export default function ConfigPage() {
|
||||||
const [yamlLoading, setYamlLoading] = useState(false);
|
const [yamlLoading, setYamlLoading] = useState(false);
|
||||||
const [yamlSaving, setYamlSaving] = useState(false);
|
const [yamlSaving, setYamlSaving] = useState(false);
|
||||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -290,11 +292,17 @@ export default function ConfigPage() {
|
||||||
// "reset this tab", not "wipe my entire config.yaml".
|
// "reset this tab", not "wipe my entire config.yaml".
|
||||||
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
||||||
if (scopedFields.length === 0) return;
|
if (scopedFields.length === 0) return;
|
||||||
|
setConfirmReset(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeReset = () => {
|
||||||
|
if (!defaults || !config) return;
|
||||||
|
setConfirmReset(false);
|
||||||
|
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
||||||
|
if (scopedFields.length === 0) return;
|
||||||
const scopeLabel = isSearching
|
const scopeLabel = isSearching
|
||||||
? t.config.searchResults
|
? t.config.searchResults
|
||||||
: prettyCategoryName(activeCategory);
|
: prettyCategoryName(activeCategory);
|
||||||
const message = t.config.confirmResetScope.replace("{scope}", scopeLabel);
|
|
||||||
if (!window.confirm(message)) return;
|
|
||||||
let next: Record<string, unknown> = config;
|
let next: Record<string, unknown> = config;
|
||||||
for (const [key] of scopedFields) {
|
for (const [key] of scopedFields) {
|
||||||
next = setNestedValue(next, key, getNestedValue(defaults, key));
|
next = setNestedValue(next, key, getNestedValue(defaults, key));
|
||||||
|
|
@ -627,6 +635,22 @@ export default function ConfigPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PluginSlot name="config:bottom" />
|
<PluginSlot name="config:bottom" />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmReset}
|
||||||
|
onCancel={() => setConfirmReset(false)}
|
||||||
|
onConfirm={executeReset}
|
||||||
|
title={t.config.confirmResetScope.replace(
|
||||||
|
"{scope}",
|
||||||
|
isSearching
|
||||||
|
? t.config.searchResults
|
||||||
|
: prettyCategoryName(activeCategory),
|
||||||
|
)}
|
||||||
|
description={`This will reset ${
|
||||||
|
(isSearching ? searchMatchedFields : activeFields).length
|
||||||
|
} field(s) to their default values.`}
|
||||||
|
destructive
|
||||||
|
confirmLabel={t.config.resetDefaults}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||||
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react";
|
||||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
import { Button } from "@nous-research/ui/ui/components/button";
|
import { Button } from "@nous-research/ui/ui/components/button";
|
||||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||||
|
|
@ -10,11 +10,13 @@ import type { CronJob } from "@/lib/api";
|
||||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||||
|
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||||
import { Toast } from "@/components/Toast";
|
import { Toast } from "@/components/Toast";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
import { PluginSlot } from "@/plugins";
|
import { PluginSlot } from "@/plugins";
|
||||||
|
|
||||||
function formatTime(iso?: string | null): string {
|
function formatTime(iso?: string | null): string {
|
||||||
|
|
@ -80,11 +82,18 @@ export default function CronPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { setEnd } = usePageHeader();
|
||||||
|
|
||||||
// New job form state
|
// New job modal state
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [schedule, setSchedule] = useState("");
|
const [schedule, setSchedule] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||||
|
const createModalRef = useModalBehavior({
|
||||||
|
open: createModalOpen,
|
||||||
|
onClose: closeCreateModal,
|
||||||
|
});
|
||||||
const [deliver, setDeliver] = useState("local");
|
const [deliver, setDeliver] = useState("local");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
|
@ -118,6 +127,7 @@ export default function CronPage() {
|
||||||
setSchedule("");
|
setSchedule("");
|
||||||
setName("");
|
setName("");
|
||||||
setDeliver("local");
|
setDeliver("local");
|
||||||
|
setCreateModalOpen(false);
|
||||||
loadJobs();
|
loadJobs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
||||||
|
|
@ -181,6 +191,22 @@ export default function CronPage() {
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Put "Create" button in page header
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setEnd(
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{t.common.create}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
setEnd(null);
|
||||||
|
};
|
||||||
|
}, [setEnd, t.common.create, loading]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-24">
|
<div className="flex items-center justify-center py-24">
|
||||||
|
|
@ -213,86 +239,110 @@ export default function CronPage() {
|
||||||
loading={jobDelete.isDeleting}
|
loading={jobDelete.isDeleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
{/* Create job modal */}
|
||||||
<CardHeader>
|
{createModalOpen && (
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<div
|
||||||
<Plus className="h-4 w-4" />
|
ref={createModalRef}
|
||||||
{t.cron.newJob}
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||||
</CardTitle>
|
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
|
||||||
</CardHeader>
|
role="dialog"
|
||||||
<CardContent>
|
aria-modal="true"
|
||||||
<div className="grid gap-4">
|
aria-labelledby="create-cron-title"
|
||||||
<div className="grid gap-2">
|
>
|
||||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
<div className="relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col">
|
||||||
<Input
|
<Button
|
||||||
id="cron-name"
|
ghost
|
||||||
placeholder={t.cron.namePlaceholder}
|
size="icon"
|
||||||
value={name}
|
onClick={() => setCreateModalOpen(false)}
|
||||||
onChange={(e) => setName(e.target.value)}
|
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||||
/>
|
aria-label="Close"
|
||||||
</div>
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<header className="p-5 pb-3 border-b border-border">
|
||||||
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
<h2
|
||||||
<textarea
|
id="create-cron-title"
|
||||||
id="cron-prompt"
|
className="font-display text-base tracking-wider uppercase"
|
||||||
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
>
|
||||||
placeholder={t.cron.promptPlaceholder}
|
{t.cron.newJob}
|
||||||
value={prompt}
|
</h2>
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
</header>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="p-5 grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cron-schedule"
|
id="cron-name"
|
||||||
placeholder={t.cron.schedulePlaceholder}
|
autoFocus
|
||||||
value={schedule}
|
placeholder={t.cron.namePlaceholder}
|
||||||
onChange={(e) => setSchedule(e.target.value)}
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
||||||
<Select
|
<textarea
|
||||||
id="cron-deliver"
|
id="cron-prompt"
|
||||||
value={deliver}
|
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||||
onValueChange={(v) => setDeliver(v)}
|
placeholder={t.cron.promptPlaceholder}
|
||||||
>
|
value={prompt}
|
||||||
<SelectOption value="local">
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
{t.cron.delivery.local}
|
/>
|
||||||
</SelectOption>
|
|
||||||
<SelectOption value="telegram">
|
|
||||||
{t.cron.delivery.telegram}
|
|
||||||
</SelectOption>
|
|
||||||
<SelectOption value="discord">
|
|
||||||
{t.cron.delivery.discord}
|
|
||||||
</SelectOption>
|
|
||||||
<SelectOption value="slack">
|
|
||||||
{t.cron.delivery.slack}
|
|
||||||
</SelectOption>
|
|
||||||
<SelectOption value="email">
|
|
||||||
{t.cron.delivery.email}
|
|
||||||
</SelectOption>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
||||||
|
<Input
|
||||||
|
id="cron-schedule"
|
||||||
|
placeholder={t.cron.schedulePlaceholder}
|
||||||
|
value={schedule}
|
||||||
|
onChange={(e) => setSchedule(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||||
|
<Select
|
||||||
|
id="cron-deliver"
|
||||||
|
value={deliver}
|
||||||
|
onValueChange={(v) => setDeliver(v)}
|
||||||
|
>
|
||||||
|
<SelectOption value="local">
|
||||||
|
{t.cron.delivery.local}
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption value="telegram">
|
||||||
|
{t.cron.delivery.telegram}
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption value="discord">
|
||||||
|
{t.cron.delivery.discord}
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption value="slack">
|
||||||
|
{t.cron.delivery.slack}
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption value="email">
|
||||||
|
{t.cron.delivery.email}
|
||||||
|
</SelectOption>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
prefix={<Plus />}
|
prefix={creating ? <Spinner /> : <Plus />}
|
||||||
className="w-full"
|
|
||||||
>
|
>
|
||||||
{creating ? t.common.creating : t.common.create}
|
{creating ? t.common.creating : t.common.create}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<H2
|
<H2
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -35,6 +35,7 @@ import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
import { PluginSlot } from "@/plugins";
|
import { PluginSlot } from "@/plugins";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
@ -132,7 +133,7 @@ function EnvVarRow({
|
||||||
// Compact inline row for unset, non-editing keys (used inside provider groups)
|
// Compact inline row for unset, non-editing keys (used inside provider groups)
|
||||||
if (compact && !info.is_set && !isEditing) {
|
if (compact && !info.is_set && !isEditing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden opacity-50 hover:opacity-100 transition-opacity">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||||
{varKey}
|
{varKey}
|
||||||
|
|
@ -168,7 +169,7 @@ function EnvVarRow({
|
||||||
// Non-compact unset row
|
// Non-compact unset row
|
||||||
if (!info.is_set && !isEditing) {
|
if (!info.is_set && !isEditing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden opacity-60 hover:opacity-100 transition-opacity">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||||
{varKey}
|
{varKey}
|
||||||
|
|
@ -203,7 +204,7 @@ function EnvVarRow({
|
||||||
|
|
||||||
// Full expanded row for set keys or keys being edited
|
// Full expanded row for set keys or keys being edited
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 border border-border p-4">
|
<div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
|
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
|
||||||
|
|
@ -493,6 +494,7 @@ export default function EnvPage() {
|
||||||
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
|
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { setAfterTitle } = usePageHeader();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
|
|
@ -501,6 +503,58 @@ export default function EnvPage() {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Scroll-to sub-nav in the page header
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const items: { id: string; label: string }[] = [
|
||||||
|
{ id: "section-oauth", label: "OAuth" },
|
||||||
|
{ id: "section-providers", label: "Providers" },
|
||||||
|
];
|
||||||
|
if (vars) {
|
||||||
|
const categories = ["tool", "messaging", "setting"];
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
tool: "Tools",
|
||||||
|
messaging: "Messaging",
|
||||||
|
setting: "Settings",
|
||||||
|
};
|
||||||
|
for (const cat of categories) {
|
||||||
|
const hasEntries = Object.values(vars).some(
|
||||||
|
(info) => info.category === cat,
|
||||||
|
);
|
||||||
|
if (hasEntries) {
|
||||||
|
items.push({ id: `section-${cat}`, label: CATEGORY_LABELS[cat] ?? cat });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [vars]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!vars) {
|
||||||
|
setAfterTitle(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollTo = (id: string) => {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
setAfterTitle(
|
||||||
|
<nav className="flex items-center gap-1" aria-label="Jump to section">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollTo(s.id)}
|
||||||
|
className="cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
setAfterTitle(null);
|
||||||
|
};
|
||||||
|
}, [vars, sections, setAfterTitle]);
|
||||||
|
|
||||||
const handleSave = async (key: string) => {
|
const handleSave = async (key: string) => {
|
||||||
const value = edits[key];
|
const value = edits[key];
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
@ -701,12 +755,14 @@ export default function EnvPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OAuthProvidersCard
|
<div id="section-oauth">
|
||||||
onError={(msg) => showToast(msg, "error")}
|
<OAuthProvidersCard
|
||||||
onSuccess={(msg) => showToast(msg, "success")}
|
onError={(msg) => showToast(msg, "error")}
|
||||||
/>
|
onSuccess={(msg) => showToast(msg, "success")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card id="section-providers">
|
||||||
<CardHeader className="border-b border-border bg-card">
|
<CardHeader className="border-b border-border bg-card">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="h-5 w-5 text-muted-foreground" />
|
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||||
|
|
@ -750,7 +806,7 @@ export default function EnvPage() {
|
||||||
if (totalEntries === 0) return null;
|
if (totalEntries === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={category}>
|
<Card key={category} id={`section-${category}`}>
|
||||||
<CardHeader className="border-b border-border bg-card">
|
<CardHeader className="border-b border-border bg-card">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
|
@ -762,7 +818,7 @@ export default function EnvPage() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="grid gap-3 pt-4">
|
<CardContent className="grid gap-3 pt-4 overflow-hidden">
|
||||||
{setEntries.map(([key, info]) => (
|
{setEntries.map(([key, info]) => (
|
||||||
<EnvVarRow
|
<EnvVarRow
|
||||||
key={key}
|
key={key}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Settings2,
|
Settings2,
|
||||||
Star,
|
Star,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
@ -25,6 +26,8 @@ import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||||
import { Stats } from "@nous-research/ui/ui/components/stats";
|
import { Stats } from "@nous-research/ui/ui/components/stats";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
import { PluginSlot } from "@/plugins";
|
import { PluginSlot } from "@/plugins";
|
||||||
|
|
@ -91,27 +94,39 @@ function TokenBar({
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
{ value: cacheRead, color: "bg-blue-400/60", label: "Cache Read" },
|
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
|
||||||
{ value: reasoning, color: "bg-purple-400/60", label: "Reasoning" },
|
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
|
||||||
{ value: input, color: "bg-[#ffe6cb]/70", label: "Input" },
|
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
|
||||||
{ value: output, color: "bg-emerald-500/70", label: "Output" },
|
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
|
||||||
].filter((s) => s.value > 0);
|
].filter((s) => s.value > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<div className="flex h-2 w-full overflow-hidden rounded-sm bg-muted/30">
|
{/* Stacked bar — segments fill proportionally to their share of total */}
|
||||||
|
<div className="relative flex min-h-[1.5rem] w-full items-stretch overflow-hidden">
|
||||||
{segments.map((s, i) => (
|
{segments.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`${s.color} transition-all duration-300`}
|
className={`${s.color} relative flex items-center transition-all duration-300`}
|
||||||
style={{ width: `${(s.value / total) * 100}%` }}
|
style={{ width: `${(s.value / total) * 100}%` }}
|
||||||
/>
|
>
|
||||||
|
{/* Stepped fill pattern overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-30"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"repeating-linear-gradient(to right, transparent 0 0.4rem, currentColor 0.4rem calc(0.4rem + 1px))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
|
||||||
{segments.map((s, i) => (
|
{segments.map((s, i) => (
|
||||||
<span key={i} className="flex items-center gap-1">
|
<span key={i} className="flex items-center gap-1">
|
||||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.color}`} />
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
|
||||||
{s.label} {formatTokens(s.value)}
|
{s.label} {formatTokens(s.value)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -378,7 +393,7 @@ function ModelCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-0">
|
<CardContent className="space-y-3 pt-3">
|
||||||
<TokenBar
|
<TokenBar
|
||||||
input={entry.input_tokens}
|
input={entry.input_tokens}
|
||||||
output={entry.output_tokens}
|
output={entry.output_tokens}
|
||||||
|
|
@ -445,6 +460,157 @@ type PickerTarget =
|
||||||
| { kind: "main" }
|
| { kind: "main" }
|
||||||
| { kind: "aux"; task: string };
|
| { kind: "aux"; task: string };
|
||||||
|
|
||||||
|
function AuxiliaryTasksModal({
|
||||||
|
aux,
|
||||||
|
refreshKey,
|
||||||
|
onSaved,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
aux: AuxiliaryModelsResponse | null;
|
||||||
|
refreshKey: number;
|
||||||
|
onSaved(): void;
|
||||||
|
onClose(): void;
|
||||||
|
}) {
|
||||||
|
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
||||||
|
const [resetBusy, setResetBusy] = useState(false);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const modalRef = useModalBehavior({ open: true, onClose });
|
||||||
|
|
||||||
|
const resetAllAux = async () => {
|
||||||
|
setConfirmReset(false);
|
||||||
|
setResetBusy(true);
|
||||||
|
try {
|
||||||
|
await api.setModelAssignment({
|
||||||
|
scope: "auxiliary",
|
||||||
|
task: "__reset__",
|
||||||
|
provider: "",
|
||||||
|
model: "",
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
} finally {
|
||||||
|
setResetBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="aux-modal-title"
|
||||||
|
>
|
||||||
|
<div className="relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<header className="p-5 pb-3 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between gap-3 pr-8">
|
||||||
|
<h2
|
||||||
|
id="aux-modal-title"
|
||||||
|
className="font-display text-base tracking-wider uppercase"
|
||||||
|
>
|
||||||
|
Auxiliary Tasks
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
outlined
|
||||||
|
onClick={() => setConfirmReset(true)}
|
||||||
|
disabled={resetBusy}
|
||||||
|
className="text-[10px] h-6"
|
||||||
|
prefix={resetBusy ? <Spinner /> : null}
|
||||||
|
>
|
||||||
|
Reset all to auto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground/80 mt-2">
|
||||||
|
Auxiliary tasks handle side-jobs like vision, session search, and
|
||||||
|
compression. <span className="font-mono">auto</span> means
|
||||||
|
"use the main model". Override per-task when you want a
|
||||||
|
cheap/fast model for a specific job.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-1">
|
||||||
|
{AUX_TASKS.map((t) => {
|
||||||
|
const cur = aux?.tasks.find((a) => a.task === t.key);
|
||||||
|
const isAuto =
|
||||||
|
!cur || cur.provider === "auto" || !cur.provider;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.key}
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-2 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-xs font-medium">{t.label}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">
|
||||||
|
{t.hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||||
|
{isAuto
|
||||||
|
? "auto (use main model)"
|
||||||
|
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
outlined
|
||||||
|
onClick={() => setPicker({ kind: "aux", task: t.key })}
|
||||||
|
className="text-[10px] h-6"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{picker && picker.kind === "aux" && (
|
||||||
|
<ModelPickerDialog
|
||||||
|
key={`picker-${refreshKey}`}
|
||||||
|
loader={api.getModelOptions}
|
||||||
|
alwaysGlobal
|
||||||
|
title={`Set Auxiliary: ${
|
||||||
|
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
|
||||||
|
picker.task
|
||||||
|
}`}
|
||||||
|
onApply={async ({ provider, model }) => {
|
||||||
|
await api.setModelAssignment({
|
||||||
|
scope: "auxiliary",
|
||||||
|
task: picker.task,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
}}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmReset}
|
||||||
|
onCancel={() => setConfirmReset(false)}
|
||||||
|
onConfirm={() => void resetAllAux()}
|
||||||
|
title="Reset auxiliary models"
|
||||||
|
description="Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set."
|
||||||
|
destructive
|
||||||
|
confirmLabel="Reset all"
|
||||||
|
loading={resetBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModelSettingsPanel({
|
function ModelSettingsPanel({
|
||||||
aux,
|
aux,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
|
|
@ -454,9 +620,8 @@ function ModelSettingsPanel({
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
onSaved(): void;
|
onSaved(): void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [auxModalOpen, setAuxModalOpen] = useState(false);
|
||||||
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
||||||
const [resetBusy, setResetBusy] = useState(false);
|
|
||||||
|
|
||||||
const mainProv = aux?.main.provider ?? "";
|
const mainProv = aux?.main.provider ?? "";
|
||||||
const mainModel = aux?.main.model ?? "";
|
const mainModel = aux?.main.model ?? "";
|
||||||
|
|
@ -476,23 +641,10 @@ function ModelSettingsPanel({
|
||||||
onSaved();
|
onSaved();
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetAllAux = async () => {
|
// Count how many aux tasks have overrides
|
||||||
if (!window.confirm("Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set.")) {
|
const auxOverrideCount = aux?.tasks.filter(
|
||||||
return;
|
(a) => a.provider && a.provider !== "auto",
|
||||||
}
|
).length ?? 0;
|
||||||
setResetBusy(true);
|
|
||||||
try {
|
|
||||||
await api.setModelAssignment({
|
|
||||||
scope: "auxiliary",
|
|
||||||
task: "__reset__",
|
|
||||||
provider: "",
|
|
||||||
model: "",
|
|
||||||
});
|
|
||||||
onSaved();
|
|
||||||
} finally {
|
|
||||||
setResetBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -505,21 +657,10 @@ function ModelSettingsPanel({
|
||||||
applies to new sessions
|
applies to new sessions
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
outlined
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{expanded ? "Hide auxiliary" : "Show auxiliary"}
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-3 w-3 transition-transform ${expanded ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-3 pt-0">
|
<CardContent className="space-y-3 pt-3">
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
@ -544,85 +685,41 @@ function ModelSettingsPanel({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auxiliary rows */}
|
{/* Auxiliary tasks summary + open modal */}
|
||||||
{expanded && (
|
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||||
<div className="space-y-1 border-t border-border/50 pt-3">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between pb-1">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<Cpu className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">
|
||||||
Auxiliary tasks
|
Auxiliary tasks
|
||||||
</div>
|
</span>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
<div className="text-xs font-mono text-muted-foreground truncate">
|
||||||
outlined
|
{auxOverrideCount > 0
|
||||||
onClick={resetAllAux}
|
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
|
||||||
disabled={resetBusy}
|
: `${AUX_TASKS.length} tasks · all auto`}
|
||||||
className="text-[10px] h-6"
|
|
||||||
prefix={resetBusy ? <Spinner /> : null}
|
|
||||||
>
|
|
||||||
Reset all to auto
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[10px] text-muted-foreground/80 pb-2">
|
|
||||||
Auxiliary tasks handle side-jobs like vision, session search, and
|
|
||||||
compression. <span className="font-mono">auto</span> means
|
|
||||||
"use the main model". Override per-task when you want a
|
|
||||||
cheap/fast model for a specific job.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{AUX_TASKS.map((t) => {
|
|
||||||
const cur = aux?.tasks.find((a) => a.task === t.key);
|
|
||||||
const isAuto =
|
|
||||||
!cur || cur.provider === "auto" || !cur.provider;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={t.key}
|
|
||||||
className="flex items-center justify-between gap-3 px-3 py-1.5 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xs font-medium">{t.label}</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground/60">
|
|
||||||
{t.hint}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
|
||||||
{isAuto
|
|
||||||
? "auto (use main model)"
|
|
||||||
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
outlined
|
|
||||||
onClick={() => setPicker({ kind: "aux", task: t.key })}
|
|
||||||
className="text-[10px] h-6"
|
|
||||||
>
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Button
|
||||||
|
size="sm"
|
||||||
|
outlined
|
||||||
|
onClick={() => setAuxModalOpen(true)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{picker && (
|
{picker && (
|
||||||
<ModelPickerDialog
|
<ModelPickerDialog
|
||||||
key={`picker-${refreshKey}`}
|
key={`picker-${refreshKey}`}
|
||||||
loader={api.getModelOptions}
|
loader={api.getModelOptions}
|
||||||
alwaysGlobal
|
alwaysGlobal
|
||||||
title={
|
title="Set Main Model"
|
||||||
picker.kind === "main"
|
|
||||||
? "Set Main Model"
|
|
||||||
: `Set Auxiliary: ${
|
|
||||||
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
|
|
||||||
picker.task
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
onApply={async ({ provider, model }) => {
|
onApply={async ({ provider, model }) => {
|
||||||
await applyAssignment({
|
await applyAssignment({
|
||||||
scope: picker.kind === "main" ? "main" : "auxiliary",
|
scope: "main",
|
||||||
task: picker.kind === "main" ? "" : picker.task,
|
task: "",
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
});
|
});
|
||||||
|
|
@ -630,6 +727,15 @@ function ModelSettingsPanel({
|
||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{auxModalOpen && (
|
||||||
|
<AuxiliaryTasksModal
|
||||||
|
aux={aux}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
onSaved={onSaved}
|
||||||
|
onClose={() => setAuxModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
@ -725,28 +831,14 @@ export default function ModelsPage() {
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PluginSlot name="models:top" />
|
<PluginSlot name="models:top" />
|
||||||
|
|
||||||
<ModelSettingsPanel
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
aux={aux}
|
<ModelSettingsPanel
|
||||||
refreshKey={saveKey}
|
aux={aux}
|
||||||
onSaved={onAssigned}
|
refreshKey={saveKey}
|
||||||
/>
|
onSaved={onAssigned}
|
||||||
|
/>
|
||||||
|
|
||||||
{loading && !data && (
|
{data && (
|
||||||
<div className="flex items-center justify-center py-24">
|
|
||||||
<Spinner className="text-2xl text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-6">
|
|
||||||
<p className="text-sm text-destructive text-center">{error}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
|
||||||
<>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-6">
|
<CardContent className="py-6">
|
||||||
<Stats
|
<Stats
|
||||||
|
|
@ -781,7 +873,25 @@ export default function ModelsPage() {
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<Spinner className="text-2xl text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<p className="text-sm text-destructive text-center">{error}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
{data.models.length > 0 ? (
|
{data.models.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{data.models.map((m, i) => (
|
{data.models.map((m, i) => (
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||||
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
|
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
@ -393,6 +394,7 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||||
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
|
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
|
||||||
|
|
||||||
const busy = rowBusy === row.name;
|
const busy = rowBusy === row.name;
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
|
||||||
const badgeTone =
|
const badgeTone =
|
||||||
row.runtime_status === "enabled"
|
row.runtime_status === "enabled"
|
||||||
|
|
@ -533,18 +535,7 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
ghost
|
ghost
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => setConfirmRemove(true)}
|
||||||
const ok =
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.confirm(t.pluginsPage.removeConfirm)
|
|
||||||
: false;
|
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
void setRuntimeLoading(row.name, async () => {
|
|
||||||
await api.removeAgentPlugin(row.name);
|
|
||||||
showToast(`${row.name} removed`, "success");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
|
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
|
@ -576,6 +567,21 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRemove}
|
||||||
|
onCancel={() => setConfirmRemove(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmRemove(false);
|
||||||
|
void setRuntimeLoading(row.name, async () => {
|
||||||
|
await api.removeAgentPlugin(row.name);
|
||||||
|
showToast(`${row.name} removed`, "success");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title={t.pluginsPage.removeConfirm}
|
||||||
|
description={`This will remove the "${row.name}" plugin from your agent.`}
|
||||||
|
destructive
|
||||||
|
confirmLabel={t.common.delete}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react";
|
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
||||||
import { H2 } from "@/components/NouiTypography";
|
import { H2 } from "@/components/NouiTypography";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { ProfileInfo } from "@/lib/api";
|
import type { ProfileInfo } from "@/lib/api";
|
||||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||||
|
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||||
import { Toast } from "@/components/Toast";
|
import { Toast } from "@/components/Toast";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
import { Button } from "@nous-research/ui/ui/components/button";
|
import { Button } from "@nous-research/ui/ui/components/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
|
||||||
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
|
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
|
||||||
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
||||||
|
|
@ -23,11 +26,18 @@ export default function ProfilesPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { setEnd } = usePageHeader();
|
||||||
|
|
||||||
// Create form
|
// Create modal
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [cloneFromDefault, setCloneFromDefault] = useState(true);
|
const [cloneFromDefault, setCloneFromDefault] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||||
|
const createModalRef = useModalBehavior({
|
||||||
|
open: createModalOpen,
|
||||||
|
onClose: closeCreateModal,
|
||||||
|
});
|
||||||
|
|
||||||
// Inline rename state
|
// Inline rename state
|
||||||
const [renamingFrom, setRenamingFrom] = useState<string | null>(null);
|
const [renamingFrom, setRenamingFrom] = useState<string | null>(null);
|
||||||
|
|
@ -68,6 +78,7 @@ export default function ProfilesPage() {
|
||||||
await api.createProfile({ name, clone_from_default: cloneFromDefault });
|
await api.createProfile({ name, clone_from_default: cloneFromDefault });
|
||||||
showToast(`${t.profiles.created}: ${name}`, "success");
|
showToast(`${t.profiles.created}: ${name}`, "success");
|
||||||
setNewName("");
|
setNewName("");
|
||||||
|
setCreateModalOpen(false);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`${t.status.error}: ${e}`, "error");
|
showToast(`${t.status.error}: ${e}`, "error");
|
||||||
|
|
@ -170,6 +181,22 @@ export default function ProfilesPage() {
|
||||||
|
|
||||||
const pendingName = profileDelete.pendingId;
|
const pendingName = profileDelete.pendingId;
|
||||||
|
|
||||||
|
// Put "Create" button in page header
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setEnd(
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{t.common.create}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
setEnd(null);
|
||||||
|
};
|
||||||
|
}, [setEnd, t.common.create, loading]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-24">
|
<div className="flex items-center justify-center py-24">
|
||||||
|
|
@ -198,51 +225,75 @@ export default function ProfilesPage() {
|
||||||
loading={profileDelete.isDeleting}
|
loading={profileDelete.isDeleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Create new profile */}
|
{/* Create profile modal */}
|
||||||
<Card>
|
{createModalOpen && (
|
||||||
<CardHeader>
|
<div
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
ref={createModalRef}
|
||||||
<Plus className="h-4 w-4" />
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||||
{t.profiles.newProfile}
|
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
|
||||||
</CardTitle>
|
role="dialog"
|
||||||
</CardHeader>
|
aria-modal="true"
|
||||||
<CardContent>
|
aria-labelledby="create-profile-title"
|
||||||
<div className="grid gap-4">
|
>
|
||||||
<div className="grid gap-2">
|
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col">
|
||||||
<Label htmlFor="profile-name">{t.profiles.name}</Label>
|
<Button
|
||||||
<Input
|
ghost
|
||||||
id="profile-name"
|
size="icon"
|
||||||
placeholder={t.profiles.namePlaceholder}
|
onClick={() => setCreateModalOpen(false)}
|
||||||
value={newName}
|
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
aria-label="Close"
|
||||||
aria-invalid={
|
>
|
||||||
newName.trim() !== "" &&
|
<X />
|
||||||
!PROFILE_NAME_RE.test(newName.trim())
|
</Button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t.profiles.nameRule}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<header className="p-5 pb-3 border-b border-border">
|
||||||
<input
|
<h2
|
||||||
type="checkbox"
|
id="create-profile-title"
|
||||||
|
className="font-display text-base tracking-wider uppercase"
|
||||||
|
>
|
||||||
|
{t.profiles.newProfile}
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="p-5 grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="profile-name">{t.profiles.name}</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-name"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t.profiles.namePlaceholder}
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCreate();
|
||||||
|
}}
|
||||||
|
aria-invalid={
|
||||||
|
newName.trim() !== "" &&
|
||||||
|
!PROFILE_NAME_RE.test(newName.trim())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.profiles.nameRule}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
id="clone-from-default"
|
||||||
checked={cloneFromDefault}
|
checked={cloneFromDefault}
|
||||||
onChange={(e) => setCloneFromDefault(e.target.checked)}
|
onChange={(e) => setCloneFromDefault(e.target.checked)}
|
||||||
|
label={t.profiles.cloneFromDefault}
|
||||||
/>
|
/>
|
||||||
{t.profiles.cloneFromDefault}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleCreate} disabled={creating}>
|
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
{creating ? t.common.creating : t.common.create}
|
{creating ? t.common.creating : t.common.create}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue