mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ddd051161 |
@@ -202,7 +202,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
|
||||
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||
|
||||
|
||||
+4
-62
@@ -1971,52 +1971,15 @@ Add MCP servers to your `config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
MCP servers can run locally over stdio or connect remotely over HTTP:
|
||||
Two transport modes are supported:
|
||||
|
||||
| Connection | Config | Example |
|
||||
| Mode | Config | Example |
|
||||
|------|--------|---------|
|
||||
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
||||
| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) |
|
||||
|
||||
Remote HTTP servers may use browser OAuth instead of static headers. In the
|
||||
WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or
|
||||
SSE, and choose **OAuth** under **Authentication**. Save the server, then choose
|
||||
**Connect**. For manual configuration, add `auth: "oauth"` and open
|
||||
**Apps → MCP** to connect. Known presets such as Xmind, Notion, and Linear add
|
||||
the config automatically on first click.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"notion": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "oauth"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nanobot opens the server's authorization page and handles the callback through
|
||||
the gateway. The tools become available immediately when hot reload succeeds;
|
||||
otherwise the WebUI asks for a restart. OAuth tokens and dynamic client
|
||||
registration data are stored in the nanobot data directory under
|
||||
`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server
|
||||
from Apps also removes its saved OAuth credentials. Normal gateway startup never
|
||||
opens a browser or registers a new OAuth client when credentials are
|
||||
missing—interactive authorization starts only after a user clicks **Connect**.
|
||||
|
||||
For a remotely accessed WebUI, HTTPS is recommended. Configure
|
||||
`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so
|
||||
nanobot can register the matching HTTPS callback and finish automatically. A
|
||||
loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP,
|
||||
nanobot instead registers a localhost callback and asks you to paste the complete
|
||||
callback URL from the browser address bar after authorization.
|
||||
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
||||
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
||||
|
||||
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||
|
||||
@@ -2343,27 +2306,6 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
||||
|
||||
### Agent Plugins v1
|
||||
|
||||
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
|
||||
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
|
||||
`skills/<name>/SKILL.md`, or both.
|
||||
|
||||
Directory presence means installed; activation is an explicit trust decision in **Apps**.
|
||||
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
|
||||
override plugin skills, which override built-ins. Enabled `stdio` servers from `mcp.json` receive
|
||||
contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit `tools.mcpServers` entries win
|
||||
name collisions. Invalid manifests, components, nested skills, and escaping paths are ignored.
|
||||
|
||||
Enabled plugins run as the nanobot user; declared permissions are descriptive, not an OS sandbox.
|
||||
The optional `extensions.dev.nanobot.installCommand` is a shell-free argv run once per version
|
||||
before local enable. Remote setup requires `tools.webuiAllowRemotePackageInstall`. The optional
|
||||
`extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
|
||||
|
||||
WebUI-installed CLI Apps use the same package layout as skills-only plugins. Their external
|
||||
executables remain managed by the CLI Apps installer; update refreshes the package and uninstall
|
||||
removes it. Future catalogs can acquire and place packages before using this same activation path.
|
||||
|
||||
## Tool Hint Max Length
|
||||
|
||||
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||
|
||||
@@ -30,15 +30,10 @@ remote HTTP endpoint.
|
||||
For local interactive setup:
|
||||
|
||||
1. Run `nanobot webui` and open **Apps**.
|
||||
2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server.
|
||||
For a custom OAuth server, choose **OAuth** under **Authentication**, save it,
|
||||
and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to
|
||||
**Connect**. Approve access in the browser window. HTTPS and localhost WebUIs
|
||||
return automatically. From a remote plain-HTTP WebUI, copy the complete
|
||||
localhost callback URL from the browser address bar and paste it into nanobot.
|
||||
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
|
||||
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||
4. Save and restart when prompted.
|
||||
5. Mention the connected MCP server with `@` in the next message and ask for a small test action.
|
||||
5. Mention the integration with `@` in the next message and ask for a small test action.
|
||||
|
||||
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
||||
|
||||
@@ -63,16 +58,12 @@ Restart nanobot and ask a question that requires the MCP tool.
|
||||
- Prefer `enabledTools` over exposing every tool by default.
|
||||
- Use `toolTimeout` for slow MCP operations.
|
||||
- Use HTTP MCP only for endpoints you trust.
|
||||
- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the
|
||||
browser connection from **Apps → MCP**.
|
||||
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Stdio MCP starts a local process; review the command before enabling it.
|
||||
- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration,
|
||||
token exchange, and redirects.
|
||||
- OAuth credentials live in the nanobot data directory, not in `config.json`.
|
||||
- HTTP/SSE MCP uses nanobot's SSRF guard.
|
||||
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||
- Do not place secrets in command arguments when environment variables or
|
||||
headers can be used.
|
||||
|
||||
+4
-9
@@ -204,13 +204,8 @@ turn. The default **Ready** view shows only tools that can be used immediately:
|
||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||
Installing an adapter does not modify the native desktop or web app it
|
||||
connects to.
|
||||
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
|
||||
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
|
||||
headers. After saving an OAuth server, choose **Connect** to open its sign-in
|
||||
page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and
|
||||
localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one
|
||||
field for pasting the complete localhost callback URL.
|
||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
||||
|
||||
Apps intentionally does not list nanobot runtime support packages such as
|
||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||
@@ -231,8 +226,8 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
|
||||
It is an optional integration and does not replace nanobot's built-in web search
|
||||
provider; mention `@parallel-search` when a turn should use it.
|
||||
|
||||
After an App or MCP server is available, mention it from the composer with `@`
|
||||
to attach that tool to the next message.
|
||||
After an App or integration is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message.
|
||||
|
||||
## Skills
|
||||
|
||||
|
||||
@@ -485,8 +485,6 @@ class AgentLoop:
|
||||
config,
|
||||
provider_snapshot_loader,
|
||||
)
|
||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||
|
||||
return cls(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@@ -501,7 +499,7 @@ class AgentLoop:
|
||||
provider_retry_mode=defaults.provider_retry_mode,
|
||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
channels_config=config.channels,
|
||||
timezone=defaults.timezone,
|
||||
unified_session=defaults.unified_session,
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
"""Load and activate locally installed Agent Plugin packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from filelock import FileLock
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
|
||||
|
||||
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
||||
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
|
||||
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
|
||||
_SETUP_TIMEOUT_SECONDS = 600
|
||||
_MAX_LOGO_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPlugin:
|
||||
"""A validated, locally installed Agent Plugins v1 package."""
|
||||
|
||||
name: str
|
||||
root: Path
|
||||
version: str
|
||||
description: str
|
||||
repository: str
|
||||
display_name: str
|
||||
category: str
|
||||
accent_color: str | None
|
||||
logo: Path | None
|
||||
permissions: tuple[str, ...]
|
||||
install_command: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPluginState:
|
||||
"""Runtime state for one discovered Agent Plugin."""
|
||||
|
||||
plugin: AgentPlugin
|
||||
mcp_servers: tuple[str, ...]
|
||||
enabled: bool
|
||||
setup_required: bool
|
||||
|
||||
|
||||
def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||
"""Return installed packages found under ``<workspace>/plugins/*``."""
|
||||
workspace = workspace.expanduser().resolve()
|
||||
root = _contained_directory(workspace / "plugins", workspace)
|
||||
if root is None:
|
||||
return []
|
||||
plugins: list[AgentPlugin] = []
|
||||
for candidate in _children(root, "Agent Plugins directory"):
|
||||
plugin_root = _contained_directory(candidate, root)
|
||||
if plugin_root is None:
|
||||
continue
|
||||
plugin = _load_manifest(plugin_root)
|
||||
if plugin is not None:
|
||||
plugins.append(plugin)
|
||||
return plugins
|
||||
|
||||
|
||||
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
|
||||
"""Return skills from plugins the user has explicitly enabled."""
|
||||
return [
|
||||
skill
|
||||
for plugin in _discover_agent_plugins(workspace)
|
||||
if _enabled(workspace, plugin.name)
|
||||
for skill in _discover_plugin_skills(plugin.name, plugin.root)
|
||||
]
|
||||
|
||||
|
||||
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||
payload = _read_object(plugin_root / "plugin.json", plugin_root)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
|
||||
return None
|
||||
name = payload.get("name")
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or len(name) > 64
|
||||
or _PLUGIN_NAME.fullmatch(name) is None
|
||||
):
|
||||
logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
|
||||
return None
|
||||
extension = payload.get("extensions")
|
||||
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
|
||||
nanobot_value = extension_payload.get("dev.nanobot")
|
||||
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
|
||||
return AgentPlugin(
|
||||
name=name,
|
||||
root=plugin_root,
|
||||
version=_string(payload.get("version")),
|
||||
description=_string(payload.get("description")),
|
||||
repository=_string(payload.get("repository")),
|
||||
display_name=_string(nanobot.get("displayName")) or name,
|
||||
category=_string(nanobot.get("category")) or "Plugin",
|
||||
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
|
||||
permissions=_string_tuple(nanobot.get("permissions")),
|
||||
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
|
||||
)
|
||||
|
||||
|
||||
def agent_plugin_mcp_servers(
|
||||
workspace: Path,
|
||||
configured: dict[str, MCPServerConfig] | None = None,
|
||||
) -> dict[str, MCPServerConfig]:
|
||||
"""Merge explicitly enabled plugin MCP servers with user configuration.
|
||||
|
||||
User configuration wins on the unlikely event of a namespaced collision.
|
||||
"""
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for plugin in _discover_agent_plugins(workspace):
|
||||
if not _enabled(workspace, plugin.name):
|
||||
continue
|
||||
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||
for name, server in plugin_servers.items():
|
||||
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
|
||||
servers[host_name] = server
|
||||
configured = configured or {}
|
||||
if collisions := servers.keys() & configured.keys():
|
||||
logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
|
||||
return servers | configured
|
||||
|
||||
|
||||
def discover_agent_plugin_states(workspace: Path) -> list[AgentPluginState]:
|
||||
"""Return component and lifecycle state for discovered plugins."""
|
||||
return [
|
||||
AgentPluginState(
|
||||
plugin=plugin,
|
||||
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
|
||||
enabled=_enabled(workspace, plugin.name),
|
||||
setup_required=bool(plugin.install_command)
|
||||
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
|
||||
)
|
||||
for plugin in _discover_agent_plugins(workspace)
|
||||
]
|
||||
|
||||
|
||||
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> AgentPlugin:
|
||||
"""Enable or disable one installed plugin."""
|
||||
plugin = next((item for item in _discover_agent_plugins(workspace) if item.name == name), None)
|
||||
if plugin is None:
|
||||
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
version = plugin.version or "unknown"
|
||||
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
|
||||
if enabled:
|
||||
if plugin.install_command and _setup_version(workspace, plugin.name) != version:
|
||||
_run_install(plugin, data)
|
||||
_write_state(data / "setup-version", version)
|
||||
_write_state(data / "enabled", "1")
|
||||
else:
|
||||
(data / "enabled").unlink(missing_ok=True)
|
||||
return plugin
|
||||
|
||||
|
||||
def _string(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _string_tuple(value: object) -> tuple[str, ...]:
|
||||
items = cast(list[object], value) if isinstance(value, list) else []
|
||||
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
|
||||
|
||||
|
||||
def _accent_color(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
|
||||
|
||||
|
||||
def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
|
||||
"""Resolve nanobot's optional packaged logo extension."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.startswith("./"):
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
logo = _contained_file(plugin_root / value[2:], plugin_root)
|
||||
try:
|
||||
data = logo.read_bytes() if logo is not None else b""
|
||||
suffix = logo.suffix.lower() if logo is not None else ""
|
||||
if len(data) <= _MAX_LOGO_BYTES and (
|
||||
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||
):
|
||||
return logo
|
||||
except OSError:
|
||||
pass
|
||||
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||
return None
|
||||
|
||||
|
||||
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
|
||||
"""Validate nanobot's optional, shell-free setup command extension."""
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
items = cast(list[object], value)
|
||||
if not 1 <= len(items) <= 32 or not all(
|
||||
isinstance(item, str) and 0 < len(item) <= 4096 for item in items
|
||||
):
|
||||
return ()
|
||||
command = cast(str, items[0])
|
||||
if not command.startswith("./"):
|
||||
logger.warning("Ignoring non-relative Agent Plugin installCommand in '{}'", plugin_root)
|
||||
return ()
|
||||
executable = _contained_file(plugin_root / command[2:], plugin_root)
|
||||
if executable is None:
|
||||
logger.warning("Ignoring invalid Agent Plugin installCommand in '{}'", plugin_root)
|
||||
return ()
|
||||
return (str(executable), *(cast(str, item) for item in items[1:]))
|
||||
|
||||
|
||||
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
|
||||
payload = _read_object(plugin.root / "mcp.json", plugin.root)
|
||||
if payload is None:
|
||||
return {}
|
||||
raw_servers = payload.get("mcpServers")
|
||||
if payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA or not isinstance(raw_servers, dict):
|
||||
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
|
||||
return {}
|
||||
|
||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||
servers: dict[str, MCPServerConfig] = {}
|
||||
for name, raw in cast(dict[str, object], raw_servers).items():
|
||||
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
|
||||
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
|
||||
continue
|
||||
server = _plugin_mcp_server(raw, plugin.root, data)
|
||||
if server is None:
|
||||
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
|
||||
continue
|
||||
servers[name] = server
|
||||
return servers
|
||||
|
||||
|
||||
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
payload = cast(dict[str, object], raw)
|
||||
if payload.keys() - _MCP_SERVER_FIELDS:
|
||||
return None
|
||||
try:
|
||||
server = MCPServerConfig.model_validate(payload)
|
||||
except ValidationError:
|
||||
return None
|
||||
command = _stdio_command(server.command, root)
|
||||
cwd = _stdio_cwd(payload.get("cwd"), root, data)
|
||||
if server.type != "stdio" or command is None or cwd is None:
|
||||
return None
|
||||
if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
|
||||
return None
|
||||
return server.model_copy(
|
||||
update={
|
||||
"command": command,
|
||||
"args": [_expand(item, root, data) for item in server.args],
|
||||
"env": {
|
||||
**{key: _expand(value, root, data) for key, value in server.env.items()},
|
||||
"PLUGIN_ROOT": str(root),
|
||||
"PLUGIN_DATA": str(data),
|
||||
},
|
||||
"cwd": str(cwd),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _stdio_command(value: object, root: Path) -> str | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
executable = _contained_file(root / value[2:], root)
|
||||
return str(executable) if executable is not None else None
|
||||
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
|
||||
if value is None:
|
||||
return root
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if value.startswith("./"):
|
||||
return _contained_directory(root / value[2:], root)
|
||||
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
|
||||
if value == placeholder or value.startswith(f"{placeholder}/"):
|
||||
relative = value[len(placeholder):].lstrip("/")
|
||||
candidate = (base / relative).resolve()
|
||||
if not candidate.is_relative_to(base):
|
||||
return None
|
||||
if base == data:
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
candidate.chmod(0o700)
|
||||
return candidate if candidate.is_dir() else None
|
||||
return None
|
||||
|
||||
|
||||
def _expand(value: str, root: Path, data: Path) -> str:
|
||||
return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))
|
||||
|
||||
|
||||
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
||||
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
||||
config_root = get_config_path().expanduser().resolve().parent
|
||||
plugin_root = _private_directory(config_root / "plugin-data", config_root, create=create)
|
||||
state_root = _private_directory(plugin_root / workspace_id, plugin_root, create=create)
|
||||
data = state_root / name
|
||||
return _private_directory(data, state_root, create=True) if create else data
|
||||
|
||||
|
||||
def _private_directory(path: Path, root: Path, *, create: bool) -> Path:
|
||||
if create:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
resolved = path.resolve(strict=create)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("Agent Plugin data directory is unavailable") from exc
|
||||
if not resolved.is_relative_to(root):
|
||||
raise RuntimeError("Agent Plugin data directory escapes its parent")
|
||||
if create:
|
||||
resolved.chmod(0o700)
|
||||
return resolved
|
||||
|
||||
|
||||
def _enabled(workspace: Path, name: str) -> bool:
|
||||
return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file()
|
||||
|
||||
|
||||
def _setup_version(workspace: Path, name: str) -> str:
|
||||
try:
|
||||
return (_plugin_data_dir(workspace, name, create=False) / "setup-version").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
except (OSError, UnicodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _write_state(path: Path, value: str) -> None:
|
||||
path.write_text(value, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def _run_install(plugin: AgentPlugin, data: Path) -> None:
|
||||
env = {
|
||||
**{key: value for key in _SETUP_ENV if (value := os.environ.get(key)) is not None},
|
||||
"PLUGIN_ROOT": str(plugin.root),
|
||||
"PLUGIN_DATA": str(data),
|
||||
}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
plugin.install_command,
|
||||
cwd=plugin.root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_SETUP_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"{plugin.display_name} setup timed out") from exc
|
||||
if result.returncode:
|
||||
output = (result.stderr or result.stdout).strip()[-2000:]
|
||||
raise RuntimeError(output or f"{plugin.display_name} setup failed")
|
||||
|
||||
|
||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
||||
skills_root = _contained_directory(plugin_root / "skills", plugin_root)
|
||||
if skills_root is None:
|
||||
return []
|
||||
|
||||
skills: list[tuple[str, Path]] = []
|
||||
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
|
||||
skill_root = _contained_directory(candidate, skills_root)
|
||||
if skill_root is None:
|
||||
continue
|
||||
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
|
||||
if skill_file is None:
|
||||
continue
|
||||
try:
|
||||
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError):
|
||||
metadata = None
|
||||
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
|
||||
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
|
||||
continue
|
||||
skills.append((candidate.name, skill_file))
|
||||
return skills
|
||||
|
||||
|
||||
def _children(root: Path, label: str) -> list[Path]:
|
||||
try:
|
||||
return sorted(root.iterdir(), key=lambda path: path.name)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not inspect {}: {}", label, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _contained_directory(path: Path, root: Path) -> Path | None:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, object] | None:
|
||||
contained = _contained_file(path, root)
|
||||
if contained is None:
|
||||
return None
|
||||
try:
|
||||
value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
|
||||
return None
|
||||
return cast(dict[str, object], value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _contained_file(path: Path, root: Path) -> Path | None:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return resolved if resolved.is_file() and resolved.is_relative_to(root) else None
|
||||
+28
-62
@@ -17,48 +17,9 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
_SKILL_NAME_LINE = re.compile(r"^name\s*:.*$", re.MULTILINE)
|
||||
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
||||
|
||||
|
||||
def parse_skill_metadata(content: str) -> dict[str, object] | None:
|
||||
"""Parse a skill document's YAML frontmatter."""
|
||||
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
|
||||
return None
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
|
||||
|
||||
|
||||
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
|
||||
"""Return whether metadata satisfies the Agent Skills identity contract."""
|
||||
description = metadata.get("description")
|
||||
return (
|
||||
metadata.get("name") == name
|
||||
and len(name) <= 64
|
||||
and _SKILL_NAME.fullmatch(name) is not None
|
||||
and isinstance(description, str)
|
||||
and 1 <= len(description.strip()) <= 1024
|
||||
)
|
||||
|
||||
|
||||
def normalize_skill_document(content: str, name: str) -> str | None:
|
||||
"""Return a valid skill document with a canonical name."""
|
||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
||||
metadata = parse_skill_metadata(content)
|
||||
if match is None or metadata is None or not valid_skill_metadata(metadata | {"name": name}, name):
|
||||
return None
|
||||
frontmatter, replaced = _SKILL_NAME_LINE.subn(f"name: {name}", match.group(1), count=1)
|
||||
if not replaced:
|
||||
frontmatter = f"name: {name}\n{frontmatter}"
|
||||
return f"---\n{frontmatter.strip()}\n---\n\n{content[match.end():].lstrip()}"
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""
|
||||
Loader for agent skills.
|
||||
@@ -99,25 +60,11 @@ class SkillsLoader:
|
||||
Returns:
|
||||
List of skill info dicts with 'name', 'path', 'source'.
|
||||
"""
|
||||
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||
|
||||
plugin_skills = enabled_agent_plugin_skills(self.workspace)
|
||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
||||
seen_names = {entry["name"] for entry in skills}
|
||||
for name, path in plugin_skills:
|
||||
if name in seen_names:
|
||||
continue
|
||||
skills.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"source": "plugin",
|
||||
}
|
||||
)
|
||||
seen_names.add(name)
|
||||
workspace_names = {entry["name"] for entry in skills}
|
||||
if self.builtin_skills and self.builtin_skills.exists():
|
||||
skills.extend(
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
|
||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
||||
)
|
||||
|
||||
if self.disabled_skills:
|
||||
@@ -137,11 +84,14 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Skill content or None if not found.
|
||||
"""
|
||||
entry = next(
|
||||
(skill for skill in self.list_skills(filter_unavailable=False) if skill["name"] == name),
|
||||
None,
|
||||
)
|
||||
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
|
||||
roots = [self.workspace_skills]
|
||||
if self.builtin_skills:
|
||||
roots.append(self.builtin_skills)
|
||||
for root in roots:
|
||||
path = root / name / "SKILL.md"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return None
|
||||
|
||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||
"""
|
||||
@@ -195,7 +145,6 @@ class SkillsLoader:
|
||||
sections: list[str] = []
|
||||
groups = (
|
||||
("Workspace skills", "workspace", self.workspace_skills),
|
||||
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||
("Built-in skills", "builtin", self.builtin_skills),
|
||||
)
|
||||
for label, source, root in groups:
|
||||
@@ -329,4 +278,21 @@ class SkillsLoader:
|
||||
Returns:
|
||||
Metadata dict or None.
|
||||
"""
|
||||
return parse_skill_metadata(self.load_skill(name) or "")
|
||||
content = self.load_skill(name)
|
||||
if not content or not content.startswith("---"):
|
||||
return None
|
||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
||||
# keep values as-is so downstream consumers get correct types.
|
||||
metadata: dict[str, object] = {}
|
||||
for key, value in cast(dict[object, object], parsed).items():
|
||||
metadata[str(key)] = value
|
||||
return metadata
|
||||
|
||||
@@ -827,8 +827,7 @@ class EditFileTool(_FsTool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Perform a small, exact replacement in one file by replacing "
|
||||
"old_text with new_text. When replacing text in an existing file, "
|
||||
"old_text and new_text must be different. Use this for narrow text substitutions "
|
||||
"old_text with new_text. Use this for narrow text substitutions "
|
||||
"with old_text copied from read_file. For multi-file, structural, "
|
||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||
"multiple times, provide more context or set occurrence, line_hint, "
|
||||
@@ -863,12 +862,9 @@ class EditFileTool(_FsTool):
|
||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||
|
||||
fp = self._resolve_write(path)
|
||||
file_exists = fp.exists()
|
||||
if file_exists and old_text == new_text:
|
||||
return ToolResult.error("Error: new_text must be different from old_text.")
|
||||
|
||||
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||
if not file_exists:
|
||||
if not fp.exists():
|
||||
if old_text == "":
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(new_text, encoding="utf-8")
|
||||
|
||||
+14
-83
@@ -38,7 +38,6 @@ if TYPE_CHECKING:
|
||||
from mcp.types import Prompt, Resource
|
||||
from mcp.types import Tool as MCPToolDefinition
|
||||
|
||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
# Transient connection errors that warrant a single retry.
|
||||
@@ -185,25 +184,6 @@ def _is_transient(exc: BaseException) -> bool:
|
||||
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
||||
|
||||
|
||||
def _is_transient_connection_failure(exc: BaseException) -> bool:
|
||||
if isinstance(exc, BaseExceptionGroup):
|
||||
group = cast(BaseExceptionGroup[BaseException], exc)
|
||||
return bool(group.exceptions) and all(
|
||||
_is_transient_connection_failure(nested) for nested in group.exceptions
|
||||
)
|
||||
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc)
|
||||
|
||||
|
||||
def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None:
|
||||
if _is_transient_connection_failure(exc):
|
||||
logger.warning("MCP server '{}': transient connection failure", name)
|
||||
logger.opt(exception=exc).debug(
|
||||
"MCP server '{}' transient connection failure details", name
|
||||
)
|
||||
return
|
||||
logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint)
|
||||
|
||||
|
||||
def _is_session_terminated(exc: BaseException) -> bool:
|
||||
"""Return True when the MCP SDK reports a dead client session."""
|
||||
if _is_transient(exc):
|
||||
@@ -981,10 +961,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
||||
|
||||
|
||||
async def connect_mcp_servers(
|
||||
mcp_servers: "dict[str, MCPServerConfig]",
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
|
||||
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
|
||||
) -> dict[str, MCPConnection]:
|
||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||
|
||||
@@ -1024,29 +1001,6 @@ async def connect_mcp_servers(
|
||||
)
|
||||
return False
|
||||
|
||||
oauth_auth: httpx.Auth | None = None
|
||||
if cfg.auth == "oauth":
|
||||
if transport_type not in {"sse", "streamableHttp"}:
|
||||
logger.warning(
|
||||
"MCP server '{}': OAuth requires an SSE or Streamable HTTP transport",
|
||||
name,
|
||||
)
|
||||
return False
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
MCPAuthorizationRequiredError,
|
||||
create_mcp_oauth_auth,
|
||||
)
|
||||
|
||||
try:
|
||||
oauth_auth = await create_mcp_oauth_auth(
|
||||
name,
|
||||
cfg.url,
|
||||
(oauth_handlers or {}).get(name),
|
||||
)
|
||||
except MCPAuthorizationRequiredError:
|
||||
logger.info("MCP server '{}': waiting for browser authorization", name)
|
||||
return False
|
||||
|
||||
if transport_type == "stdio":
|
||||
command, args, env = _normalize_windows_stdio_command(
|
||||
cfg.command,
|
||||
@@ -1084,30 +1038,22 @@ async def connect_mcp_servers(
|
||||
**_pinned_transport_kwargs(),
|
||||
)
|
||||
|
||||
sse_kwargs: dict[str, Any] = {
|
||||
"httpx_client_factory": httpx_client_factory,
|
||||
}
|
||||
if oauth_auth is not None:
|
||||
sse_kwargs["auth"] = oauth_auth
|
||||
read, write = await server_stack.enter_async_context(
|
||||
sse_client(cfg.url, **sse_kwargs)
|
||||
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
|
||||
)
|
||||
elif transport_type == "streamableHttp":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
return False
|
||||
|
||||
http_client_kwargs: dict[str, Any] = {
|
||||
"headers": cfg.headers or None,
|
||||
"event_hooks": {"request": [_validate_mcp_request_url]},
|
||||
"follow_redirects": True,
|
||||
"timeout": httpx.Timeout(30.0, connect=10.0),
|
||||
**_pinned_transport_kwargs(),
|
||||
}
|
||||
if oauth_auth is not None:
|
||||
http_client_kwargs["auth"] = oauth_auth
|
||||
http_client = await server_stack.enter_async_context(
|
||||
httpx.AsyncClient(**http_client_kwargs)
|
||||
httpx.AsyncClient(
|
||||
headers=cfg.headers or None,
|
||||
event_hooks={"request": [_validate_mcp_request_url]},
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
**_pinned_transport_kwargs(),
|
||||
)
|
||||
)
|
||||
read, write, _ = await server_stack.enter_async_context(
|
||||
streamable_http_client(cfg.url, http_client=http_client)
|
||||
@@ -1236,7 +1182,7 @@ async def connect_mcp_servers(
|
||||
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||
)
|
||||
_log_mcp_connection_failure(name, e, hint)
|
||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||
return False
|
||||
|
||||
async def connect_single_server(
|
||||
@@ -1283,7 +1229,7 @@ async def connect_mcp_servers(
|
||||
try:
|
||||
result = await connect_single_server(name, cfg)
|
||||
except Exception as e:
|
||||
_log_mcp_connection_failure(name, e)
|
||||
logger.exception("MCP server '{}' connection failed: {}", name, e)
|
||||
continue
|
||||
if result[1] is not None:
|
||||
server_stacks[result[0]] = result[1]
|
||||
@@ -1340,14 +1286,10 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
"requires_restart": True,
|
||||
}
|
||||
try:
|
||||
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
config = resolve_config_env_vars(load_config())
|
||||
next_servers = agent_plugin_mcp_servers(
|
||||
config.workspace_path,
|
||||
config.tools.mcp_servers,
|
||||
)
|
||||
next_servers = dict(config.tools.mcp_servers)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||
return {
|
||||
@@ -1360,13 +1302,6 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
current_servers = dict(state._mcp_servers)
|
||||
current_names = set(current_servers)
|
||||
next_names = set(next_servers)
|
||||
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||
|
||||
authorization_pending = {
|
||||
name
|
||||
for name, cfg in next_servers.items()
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
|
||||
}
|
||||
removed = sorted(current_names - next_names)
|
||||
added = sorted(next_names - current_names)
|
||||
changed = sorted(
|
||||
@@ -1384,13 +1319,9 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
||||
retry_missing = sorted(
|
||||
name
|
||||
for name in next_names
|
||||
if name not in state._mcp_stacks
|
||||
and name not in set(added) | set(changed)
|
||||
and name not in authorization_pending
|
||||
)
|
||||
to_connect_names = sorted(
|
||||
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
|
||||
if name not in state._mcp_stacks and name not in set(added) | set(changed)
|
||||
)
|
||||
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||
connected: dict[str, MCPConnection] = {}
|
||||
if to_connect:
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"""OAuth support for remote MCP servers.
|
||||
|
||||
This module intentionally owns MCP OAuth end to end. Provider OAuth has a
|
||||
different lifecycle and storage contract, so sharing a higher-level workflow
|
||||
would couple unrelated extension boundaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from filelock import FileLock
|
||||
from loguru import logger
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
MCP_OAUTH_CALLBACK_PATH = "/auth/mcp/callback"
|
||||
_STORE_VERSION = 1
|
||||
_STORE_LOCK_TIMEOUT_S = 15
|
||||
_DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}"
|
||||
_CLIENT_URI = AnyHttpUrl("https://github.com/HKUDS/nanobot")
|
||||
_LOGO_URI = AnyHttpUrl(
|
||||
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
|
||||
"webui/public/brand/nanobot_apple_touch.png"
|
||||
)
|
||||
|
||||
|
||||
class _StoredServer(TypedDict, total=False):
|
||||
server_fingerprint: str
|
||||
write_lease: str
|
||||
tokens: dict[str, Any]
|
||||
client_info: dict[str, Any]
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class _CredentialStore(TypedDict):
|
||||
version: int
|
||||
servers: dict[str, _StoredServer]
|
||||
generations: dict[str, str]
|
||||
|
||||
|
||||
class MCPAuthorizationRequiredError(RuntimeError):
|
||||
"""Raised when a background MCP connection needs interactive authorization."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPOAuthHandlers:
|
||||
"""Browser callbacks supplied only for a user-initiated OAuth attempt."""
|
||||
|
||||
redirect_uri: str
|
||||
redirect_handler: Callable[[str], Awaitable[None]]
|
||||
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]]
|
||||
reset_credentials: bool = False
|
||||
|
||||
|
||||
def _store_path() -> Path:
|
||||
return get_data_dir() / "auth" / "mcp.json"
|
||||
|
||||
|
||||
def _server_fingerprint(server_url: str) -> str:
|
||||
return hashlib.sha256(server_url.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _empty_store() -> _CredentialStore:
|
||||
return {"version": _STORE_VERSION, "servers": {}, "generations": {}}
|
||||
|
||||
|
||||
def _stored_server(value: object) -> _StoredServer | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
raw = cast(dict[object, object], value)
|
||||
entry: _StoredServer = {}
|
||||
fingerprint = raw.get("server_fingerprint")
|
||||
if isinstance(fingerprint, str):
|
||||
entry["server_fingerprint"] = fingerprint
|
||||
write_lease = raw.get("write_lease")
|
||||
if isinstance(write_lease, str) and write_lease:
|
||||
entry["write_lease"] = write_lease
|
||||
redirect_uri = raw.get("redirect_uri")
|
||||
if isinstance(redirect_uri, str):
|
||||
entry["redirect_uri"] = redirect_uri
|
||||
tokens = raw.get("tokens")
|
||||
if isinstance(tokens, dict):
|
||||
token_values = cast(dict[object, object], tokens)
|
||||
if all(isinstance(key, str) for key in token_values):
|
||||
entry["tokens"] = cast(dict[str, Any], token_values)
|
||||
client_info = raw.get("client_info")
|
||||
if isinstance(client_info, dict):
|
||||
client_values = cast(dict[object, object], client_info)
|
||||
if all(isinstance(key, str) for key in client_values):
|
||||
entry["client_info"] = cast(dict[str, Any], client_values)
|
||||
return entry
|
||||
|
||||
|
||||
def _read_store_unlocked(path: Path) -> _CredentialStore:
|
||||
try:
|
||||
raw = cast(object, json.loads(path.read_text(encoding="utf-8")))
|
||||
except FileNotFoundError:
|
||||
return _empty_store()
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
logger.warning("Could not read MCP OAuth credentials: {}", type(exc).__name__)
|
||||
return _empty_store()
|
||||
if not isinstance(raw, dict):
|
||||
return _empty_store()
|
||||
payload = cast(dict[object, object], raw)
|
||||
raw_servers = payload.get("servers")
|
||||
if not isinstance(raw_servers, dict):
|
||||
return _empty_store()
|
||||
servers: dict[str, _StoredServer] = {}
|
||||
for name, value in cast(dict[object, object], raw_servers).items():
|
||||
entry = _stored_server(value)
|
||||
if isinstance(name, str) and entry is not None:
|
||||
servers[name] = entry
|
||||
generations: dict[str, str] = {}
|
||||
raw_generations = payload.get("generations")
|
||||
if isinstance(raw_generations, dict):
|
||||
for name, value in cast(dict[object, object], raw_generations).items():
|
||||
if isinstance(name, str) and isinstance(value, str) and value:
|
||||
generations[name] = value
|
||||
return {
|
||||
"version": _STORE_VERSION,
|
||||
"servers": servers,
|
||||
"generations": generations,
|
||||
}
|
||||
|
||||
|
||||
def _with_store_lock(path: Path) -> FileLock:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return FileLock(str(path.with_suffix(".lock")), timeout=_STORE_LOCK_TIMEOUT_S)
|
||||
|
||||
|
||||
def _write_store_unlocked(path: Path, payload: _CredentialStore) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with suppress(OSError):
|
||||
os.chmod(path.parent, 0o700)
|
||||
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
with suppress(OSError):
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
class MCPOAuthStorage:
|
||||
"""Persistent MCP SDK token storage, isolated by config name and server URL."""
|
||||
|
||||
def __init__(self, server_name: str, server_url: str) -> None:
|
||||
self.server_name = server_name
|
||||
self.server_fingerprint = _server_fingerprint(server_url)
|
||||
self._observed_generation = self._read_generation_sync()
|
||||
self._write_lease: str | None = None
|
||||
|
||||
def _read_generation_sync(self) -> str | None:
|
||||
path = _store_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
# Writes replace the whole file atomically, so this observes either side
|
||||
# of a concurrent deletion without blocking the async connection path.
|
||||
return _read_store_unlocked(path)["generations"].get(self.server_name)
|
||||
|
||||
def _generation_is_current(self, payload: _CredentialStore) -> bool:
|
||||
return payload["generations"].get(self.server_name) == self._observed_generation
|
||||
|
||||
def _entry_unlocked(self, payload: _CredentialStore) -> _StoredServer | None:
|
||||
servers = payload["servers"]
|
||||
entry = servers.get(self.server_name)
|
||||
if entry is None or entry.get("server_fingerprint") != self.server_fingerprint:
|
||||
return None
|
||||
return entry
|
||||
|
||||
def _bind_entry_unlocked(
|
||||
self,
|
||||
payload: _CredentialStore,
|
||||
*,
|
||||
create: bool,
|
||||
) -> tuple[_StoredServer | None, bool]:
|
||||
if not self._generation_is_current(payload):
|
||||
return None, False
|
||||
entry = self._entry_unlocked(payload)
|
||||
if self._write_lease is not None:
|
||||
if entry is None or entry.get("write_lease") != self._write_lease:
|
||||
return None, False
|
||||
return entry, False
|
||||
if entry is None:
|
||||
if not create:
|
||||
return None, False
|
||||
self._write_lease = secrets.token_urlsafe(24)
|
||||
entry = _StoredServer(
|
||||
server_fingerprint=self.server_fingerprint,
|
||||
write_lease=self._write_lease,
|
||||
)
|
||||
payload["servers"][self.server_name] = entry
|
||||
return entry, True
|
||||
write_lease = entry.get("write_lease")
|
||||
changed = not isinstance(write_lease, str) or not write_lease
|
||||
if changed:
|
||||
write_lease = secrets.token_urlsafe(24)
|
||||
entry["write_lease"] = write_lease
|
||||
self._write_lease = write_lease
|
||||
return entry, changed
|
||||
|
||||
def _read_entry_sync(self) -> _StoredServer | None:
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
entry, changed = self._bind_entry_unlocked(payload, create=False)
|
||||
if changed:
|
||||
_write_store_unlocked(path, payload)
|
||||
return entry
|
||||
|
||||
def _update_entry_sync(
|
||||
self,
|
||||
update: Callable[[_StoredServer], None],
|
||||
*,
|
||||
create: bool = True,
|
||||
claim: bool = False,
|
||||
) -> bool:
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
if claim:
|
||||
# A browser flow owns subsequent SDK writes until another flow
|
||||
# claims the entry or the configured server is removed.
|
||||
if not self._generation_is_current(payload):
|
||||
logger.info(
|
||||
"Ignored stale MCP OAuth credential claim for '{}'",
|
||||
self.server_name,
|
||||
)
|
||||
return False
|
||||
entry = self._entry_unlocked(payload)
|
||||
if entry is None:
|
||||
entry = _StoredServer(server_fingerprint=self.server_fingerprint)
|
||||
payload["servers"][self.server_name] = entry
|
||||
self._write_lease = secrets.token_urlsafe(24)
|
||||
entry["write_lease"] = self._write_lease
|
||||
else:
|
||||
entry, _ = self._bind_entry_unlocked(payload, create=create)
|
||||
if entry is None:
|
||||
if self._write_lease is not None:
|
||||
logger.info(
|
||||
"Ignored stale MCP OAuth credential update for '{}'",
|
||||
self.server_name,
|
||||
)
|
||||
return False
|
||||
update(entry)
|
||||
payload["version"] = _STORE_VERSION
|
||||
_write_store_unlocked(path, payload)
|
||||
return True
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
raw = entry.get("tokens") if entry is not None else None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return OAuthToken.model_validate(raw)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Ignoring invalid MCP OAuth tokens for '{}'", self.server_name)
|
||||
return None
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
raw = tokens.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry["tokens"] = raw
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update)
|
||||
|
||||
async def clear_tokens(self) -> None:
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry.pop("tokens", None)
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update, create=False)
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
raw = entry.get("client_info") if entry is not None else None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return OAuthClientInformationFull.model_validate(raw)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Ignoring invalid MCP OAuth client info for '{}'", self.server_name)
|
||||
return None
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
raw = client_info.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
def update(entry: _StoredServer) -> None:
|
||||
entry["client_info"] = raw
|
||||
|
||||
await asyncio.to_thread(self._update_entry_sync, update)
|
||||
|
||||
async def redirect_uri(self) -> str | None:
|
||||
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||
value = entry.get("redirect_uri") if entry is not None else None
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
async def prepare_redirect_uri(self, redirect_uri: str, *, reset: bool = False) -> None:
|
||||
def update(entry: _StoredServer) -> None:
|
||||
changed = entry.get("redirect_uri") != redirect_uri
|
||||
if reset:
|
||||
entry.pop("tokens", None)
|
||||
entry.pop("client_info", None)
|
||||
elif changed:
|
||||
# Dynamic registrations bind a client to its redirect URI.
|
||||
entry.pop("client_info", None)
|
||||
entry["redirect_uri"] = redirect_uri
|
||||
|
||||
claimed = await asyncio.to_thread(self._update_entry_sync, update, claim=True)
|
||||
if not claimed:
|
||||
raise MCPAuthorizationRequiredError("MCP authorization was cancelled")
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
entry = self._read_entry_sync()
|
||||
raw_tokens = entry.get("tokens") if entry is not None else None
|
||||
if not isinstance(raw_tokens, dict):
|
||||
return False
|
||||
tokens = cast(dict[str, object], raw_tokens)
|
||||
access_token = tokens.get("access_token")
|
||||
return isinstance(access_token, str) and bool(access_token)
|
||||
|
||||
|
||||
async def _missing_callback() -> tuple[str, str | None]:
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
|
||||
|
||||
async def create_mcp_oauth_auth(
|
||||
server_name: str,
|
||||
server_url: str,
|
||||
handlers: MCPOAuthHandlers | None = None,
|
||||
) -> OAuthClientProvider:
|
||||
"""Build the official MCP SDK OAuth provider for one configured server."""
|
||||
storage = MCPOAuthStorage(server_name, server_url)
|
||||
if handlers is not None:
|
||||
await storage.prepare_redirect_uri(
|
||||
handlers.redirect_uri,
|
||||
reset=handlers.reset_credentials,
|
||||
)
|
||||
redirect_uri = handlers.redirect_uri
|
||||
redirect_handler = handlers.redirect_handler
|
||||
callback_handler = handlers.callback_handler
|
||||
else:
|
||||
if not await asyncio.to_thread(storage.has_credentials):
|
||||
# Do not perform discovery or dynamic registration from a background
|
||||
# startup. Interactive OAuth begins only after an explicit user action.
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
redirect_uri = await storage.redirect_uri() or _DEFAULT_REDIRECT_URI
|
||||
|
||||
async def authorization_required(_authorization_url: str) -> None:
|
||||
await storage.clear_tokens()
|
||||
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||
|
||||
redirect_handler = authorization_required
|
||||
callback_handler = _missing_callback
|
||||
|
||||
metadata = OAuthClientMetadata(
|
||||
redirect_uris=[AnyUrl(redirect_uri)],
|
||||
token_endpoint_auth_method="none",
|
||||
client_name="nanobot",
|
||||
client_uri=_CLIENT_URI,
|
||||
logo_uri=_LOGO_URI,
|
||||
software_id="https://github.com/HKUDS/nanobot",
|
||||
)
|
||||
return OAuthClientProvider(
|
||||
server_url,
|
||||
metadata,
|
||||
storage,
|
||||
redirect_handler=redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
def mcp_oauth_has_credentials(server_name: str, server_url: str) -> bool:
|
||||
"""Return whether this exact configured MCP instance has an access token."""
|
||||
return MCPOAuthStorage(server_name, server_url).has_credentials()
|
||||
|
||||
|
||||
def delete_mcp_oauth_credentials(server_name: str) -> bool:
|
||||
"""Delete credentials for one config name without touching other MCP instances."""
|
||||
path = _store_path()
|
||||
with _with_store_lock(path):
|
||||
payload = _read_store_unlocked(path)
|
||||
servers = payload["servers"]
|
||||
removed = servers.pop(server_name, None) is not None
|
||||
# Rotate even when no entry exists so a flow created before removal cannot
|
||||
# claim the name later and resurrect credentials.
|
||||
payload["generations"][server_name] = secrets.token_urlsafe(24)
|
||||
_write_store_unlocked(path, payload)
|
||||
return removed
|
||||
+17
-51
@@ -20,7 +20,6 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.skills import normalize_skill_document
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
from nanobot.security.workspace_policy import is_path_within
|
||||
@@ -28,7 +27,6 @@ from nanobot.security.workspace_policy import is_path_within
|
||||
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
|
||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
||||
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
|
||||
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||
_CATALOG_SOURCES = (
|
||||
@@ -212,27 +210,11 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||
def _safe_skill_name(name: str) -> str:
|
||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||
if not legacy:
|
||||
clean = clean.replace("_", "-")
|
||||
return f"cli-app-{clean or 'app'}"
|
||||
|
||||
|
||||
def _plugin_skill_relative_path(name: str) -> str:
|
||||
skill_name = _skill_name(name)
|
||||
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||
|
||||
|
||||
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||
"""Return a CLI App's skill path, including the legacy location."""
|
||||
canonical = _plugin_skill_relative_path(name)
|
||||
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||
return legacy
|
||||
return canonical
|
||||
|
||||
|
||||
def _has_shell_meta(command: str) -> bool:
|
||||
return any(char in command for char in _SHELL_META_CHARS)
|
||||
|
||||
@@ -631,7 +613,7 @@ class CliAppManager:
|
||||
"name": installed_name,
|
||||
"entry_point": entry_point,
|
||||
"source": str(data.get("source") or ""),
|
||||
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
|
||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
}
|
||||
)
|
||||
@@ -657,6 +639,9 @@ class CliAppManager:
|
||||
install_cmd = str(app.get("install_cmd") or "")
|
||||
return not _has_shell_meta(install_cmd)
|
||||
|
||||
def _skill_path(self, name: str) -> Path:
|
||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
||||
|
||||
def _app_payload(
|
||||
self,
|
||||
app: dict[str, Any],
|
||||
@@ -692,7 +677,7 @@ class CliAppManager:
|
||||
"status": status,
|
||||
"logo_url": logo_url,
|
||||
"brand_color": brand_color,
|
||||
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
|
||||
"skill_installed": self._skill_path(name).is_file(),
|
||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||
}
|
||||
|
||||
@@ -728,8 +713,7 @@ class CliAppManager:
|
||||
name = str(app["name"])
|
||||
entry_point = str(app.get("entry_point") or "")
|
||||
strategy = self._strategy(app)
|
||||
skill_path = _plugin_skill_relative_path(name)
|
||||
plugin_path = f"plugins/{_skill_name(name)}"
|
||||
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
|
||||
capabilities = [
|
||||
compact_dict({
|
||||
"type": "cli",
|
||||
@@ -742,13 +726,13 @@ class CliAppManager:
|
||||
install = compact_dict({
|
||||
"supported": install_supported,
|
||||
"strategy": strategy,
|
||||
"managed_paths": [plugin_path],
|
||||
"managed_paths": [skill_path],
|
||||
"verification": ["entry_point_available"] if entry_point else [],
|
||||
})
|
||||
remove = compact_dict({
|
||||
"supported": strategy != "unsupported",
|
||||
"strategy": strategy,
|
||||
"managed_paths": [plugin_path],
|
||||
"managed_paths": [skill_path],
|
||||
"verification": (
|
||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||
if strategy not in {"bundled", "unsupported"}
|
||||
@@ -1048,10 +1032,11 @@ class CliAppManager:
|
||||
name = str(app.get("name") or "unknown")
|
||||
display = str(app.get("display_name") or name)
|
||||
entry = str(app.get("entry_point") or f"cli-anything-{name}")
|
||||
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
|
||||
description = _catalog_description(app) or f"Use {display} from nanobot."
|
||||
return f"""---
|
||||
name: {_skill_name(name)}
|
||||
description: {json.dumps(description, ensure_ascii=False)}
|
||||
name: {_safe_skill_name(name)}
|
||||
description: >-
|
||||
{description}
|
||||
---
|
||||
|
||||
# {display}
|
||||
@@ -1088,43 +1073,24 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
||||
return note + "\n" + content
|
||||
|
||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||
name = str(app["name"])
|
||||
path = self.workspace / _plugin_skill_relative_path(name)
|
||||
path = self._skill_path(str(app["name"]))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||
content = normalize_skill_document(content, _skill_name(name)) or self._fallback_skill(app)
|
||||
content = self._with_nanobot_skill_note(content, app)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
plugin_root = path.parents[2]
|
||||
manifest = compact_dict({
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": _skill_name(str(app["name"])),
|
||||
"version": str(app.get("version") or ""),
|
||||
"description": _catalog_description(app),
|
||||
})
|
||||
_write_json(plugin_root / "plugin.json", manifest)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
return path
|
||||
|
||||
def remove_skill(self, name: str) -> None:
|
||||
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||
if plugin_root.is_dir():
|
||||
shutil.rmtree(plugin_root)
|
||||
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||
if legacy_dir.is_dir():
|
||||
shutil.rmtree(legacy_dir)
|
||||
skill_dir = self._skill_path(name).parent
|
||||
if skill_dir.is_dir():
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||
|
||||
installed = self._load_installed()
|
||||
entry = self._installed_entry(app)
|
||||
installed[str(app["name"])] = entry
|
||||
self._save_installed(installed)
|
||||
self.install_skill(app)
|
||||
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||
return entry
|
||||
|
||||
def install(self, name: str) -> dict[str, Any]:
|
||||
|
||||
@@ -20,8 +20,6 @@ def runtime_lines_for_request(
|
||||
"""Return CLI App annotations from an immutable request snapshot."""
|
||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(structured, list):
|
||||
from nanobot.apps.cli.service import cli_app_skill_relative_path
|
||||
|
||||
structured_items = cast(list[Any], structured)
|
||||
mentions = [
|
||||
cast(Mapping[str, Any], item) for item in structured_items
|
||||
@@ -34,7 +32,7 @@ def runtime_lines_for_request(
|
||||
f"@{str(item['name']).strip().lower()} "
|
||||
f"(installed; tool=run_cli_app; "
|
||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
|
||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||
for item in mentions
|
||||
if str(item.get("name") or "").strip()
|
||||
|
||||
@@ -2090,14 +2090,6 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
||||
assert body["hot_reload"]["ok"] is True
|
||||
assert body["restart_required_sections"] == []
|
||||
|
||||
disabled = await _webui_mutate(
|
||||
channel,
|
||||
"settings.mcp.disable",
|
||||
{"name": "browserbase"},
|
||||
)
|
||||
assert disabled.status_code == 200
|
||||
assert preset_queries[-1][0] == "disable"
|
||||
|
||||
custom = await _webui_mutate(
|
||||
channel,
|
||||
"settings.mcp.custom",
|
||||
|
||||
@@ -373,7 +373,6 @@ class MCPServerConfig(Base):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
|
||||
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
||||
auth: Literal["oauth"] | None = None # Remote MCP OAuth; tokens are stored outside config
|
||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
"""Gateway-owned browser authorization flows for remote MCP servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import SplitResult, parse_qs, urlsplit, urlunsplit
|
||||
|
||||
from nanobot.agent.tools.mcp import MCPConnection, connect_mcp_servers
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH, MCPOAuthHandlers
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
from nanobot.security.network import validate_url_target
|
||||
from nanobot.webui.http_utils import is_loopback_host
|
||||
|
||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||
_FLOW_TTL_S = 300
|
||||
_START_WAIT_S = 20
|
||||
_OAUTH_ERROR_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,80}$")
|
||||
|
||||
|
||||
class McpOAuthError(Exception):
|
||||
"""Safe WebUI error for an MCP OAuth request."""
|
||||
|
||||
def __init__(self, message: str, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class _OAuthCallbackError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _McpOAuthFlow:
|
||||
flow_id: str
|
||||
name: str
|
||||
cfg: MCPServerConfig
|
||||
redirect_uri: str
|
||||
manual_callback: bool
|
||||
expires_at: float
|
||||
authorization_ready: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
callback_result: asyncio.Future[tuple[str, str | None]] | None = None
|
||||
task: asyncio.Task[bool] | None = None
|
||||
authorization_url: str | None = None
|
||||
state: str | None = None
|
||||
callback_received: bool = False
|
||||
error: str | None = None
|
||||
reload_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _parse_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, SplitResult, int | None]:
|
||||
cleaned = redirect_uri.strip()
|
||||
parsed = urlsplit(cleaned)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise McpOAuthError("Invalid MCP OAuth callback URL") from exc
|
||||
if (
|
||||
not parsed.netloc
|
||||
or not parsed.hostname
|
||||
or parsed.path != MCP_OAUTH_CALLBACK_PATH
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
raise McpOAuthError("Invalid MCP OAuth callback URL")
|
||||
return cleaned, parsed, port
|
||||
|
||||
|
||||
def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str:
|
||||
"""Allow HTTPS callbacks, plus loopback HTTP for a local gateway."""
|
||||
cleaned, parsed, _port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||
if parsed.scheme == "https":
|
||||
return cleaned
|
||||
if parsed.scheme == "http" and is_loopback_host(parsed.netloc):
|
||||
return cleaned
|
||||
raise McpOAuthError("MCP OAuth callbacks must use HTTPS or localhost")
|
||||
|
||||
|
||||
def prepare_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, bool]:
|
||||
"""Use a pasteable loopback callback when a remote WebUI is served over HTTP."""
|
||||
cleaned, parsed, port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||
if parsed.scheme != "http" or is_loopback_host(parsed.netloc):
|
||||
return validate_mcp_oauth_redirect_uri(cleaned), False
|
||||
|
||||
loopback = "127.0.0.1" if port is None else f"127.0.0.1:{port}"
|
||||
manual_redirect_uri = urlunsplit(("http", loopback, parsed.path, "", ""))
|
||||
return validate_mcp_oauth_redirect_uri(manual_redirect_uri), True
|
||||
|
||||
|
||||
class McpOAuthManager:
|
||||
"""Own short-lived browser flows while the gateway process is running."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._flows: dict[str, _McpOAuthFlow] = {}
|
||||
self._states: dict[str, str] = {}
|
||||
|
||||
async def start(
|
||||
self,
|
||||
name: str,
|
||||
cfg: MCPServerConfig,
|
||||
redirect_uri: str,
|
||||
*,
|
||||
reload_mcp: McpReload,
|
||||
reset_credentials: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
self._prune()
|
||||
redirect_uri, manual_callback = prepare_mcp_oauth_redirect_uri(redirect_uri)
|
||||
await self._cancel_name(name)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
now = time.monotonic()
|
||||
flow = _McpOAuthFlow(
|
||||
flow_id=secrets.token_urlsafe(24),
|
||||
name=name,
|
||||
cfg=cfg,
|
||||
redirect_uri=redirect_uri,
|
||||
manual_callback=manual_callback,
|
||||
expires_at=now + _FLOW_TTL_S,
|
||||
callback_result=loop.create_future(),
|
||||
)
|
||||
self._flows[flow.flow_id] = flow
|
||||
handlers = MCPOAuthHandlers(
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_handler=lambda url: self._receive_authorization_url(flow, url),
|
||||
callback_handler=lambda: self._wait_for_callback(flow),
|
||||
reset_credentials=reset_credentials,
|
||||
)
|
||||
flow.task = asyncio.create_task(
|
||||
self._connect_and_reload(flow, handlers, reload_mcp),
|
||||
name=f"mcp-oauth:{name}",
|
||||
)
|
||||
|
||||
ready_waiter = asyncio.create_task(flow.authorization_ready.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{ready_waiter, flow.task},
|
||||
timeout=_START_WAIT_S,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
ready_waiter.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ready_waiter
|
||||
return self._payload(flow)
|
||||
|
||||
async def status(self, flow_id: str) -> dict[str, Any]:
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
return self._payload(flow)
|
||||
|
||||
def submit_callback(
|
||||
self,
|
||||
*,
|
||||
state: str,
|
||||
code: str | None,
|
||||
error: str | None,
|
||||
) -> str:
|
||||
self._prune()
|
||||
flow_id = self._states.pop(state, None)
|
||||
if flow_id is None:
|
||||
raise McpOAuthError("This MCP authorization request has expired", status=410)
|
||||
flow = self._flow(flow_id)
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is None or callback_result.done():
|
||||
raise McpOAuthError("This MCP authorization callback was already used", status=409)
|
||||
|
||||
flow.callback_received = True
|
||||
if error:
|
||||
safe_error = error if _OAUTH_ERROR_RE.fullmatch(error) else "authorization_failed"
|
||||
flow.error = f"Authorization was not completed ({safe_error})."
|
||||
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||
raise McpOAuthError(flow.error)
|
||||
elif not code or len(code) > 8192:
|
||||
flow.error = "The MCP server did not return an authorization code."
|
||||
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||
raise McpOAuthError(flow.error)
|
||||
else:
|
||||
callback_result.set_result((code, state))
|
||||
return flow.name
|
||||
|
||||
def submit_callback_url(self, *, flow_id: str, callback_url: str) -> dict[str, Any]:
|
||||
"""Complete a flow from a full browser callback URL pasted into the WebUI."""
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
parsed = urlsplit(callback_url.strip())
|
||||
expected = urlsplit(flow.redirect_uri)
|
||||
if (
|
||||
not parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.scheme != expected.scheme
|
||||
or parsed.netloc != expected.netloc
|
||||
or parsed.path != expected.path
|
||||
):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
try:
|
||||
query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16)
|
||||
except ValueError as exc:
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
) from exc
|
||||
|
||||
states = query.get("state", [])
|
||||
state = states[0] if len(states) == 1 else ""
|
||||
if not state or state != flow.state:
|
||||
raise McpOAuthError(
|
||||
"This callback belongs to a different or expired authorization request. "
|
||||
"Start again.",
|
||||
status=410,
|
||||
)
|
||||
|
||||
codes = query.get("code", [])
|
||||
errors = query.get("error", [])
|
||||
if len(codes) > 1 or len(errors) > 1 or (codes and errors):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
code = codes[0] if len(codes) == 1 else None
|
||||
error = errors[0] if len(errors) == 1 else None
|
||||
if (not code and not error) or (code is not None and len(code) > 8192):
|
||||
raise McpOAuthError(
|
||||
"Paste the complete callback URL from the browser address bar."
|
||||
)
|
||||
|
||||
self.submit_callback(state=state, code=code, error=error)
|
||||
return self._payload(flow)
|
||||
|
||||
async def cancel(self, flow_id: str) -> dict[str, Any]:
|
||||
self._prune()
|
||||
flow = self._flow(flow_id)
|
||||
await self._cancel_flow(flow)
|
||||
return self._payload(flow)
|
||||
|
||||
async def _receive_authorization_url(
|
||||
self,
|
||||
flow: _McpOAuthFlow,
|
||||
authorization_url: str,
|
||||
) -> None:
|
||||
parsed = urlsplit(authorization_url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
):
|
||||
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
ok, _error = validate_url_target(authorization_url)
|
||||
if not ok:
|
||||
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
states = parse_qs(parsed.query).get("state", [])
|
||||
state = states[0] if len(states) == 1 else ""
|
||||
if not state or len(state) > 512:
|
||||
flow.error = "The MCP server returned an invalid authorization URL."
|
||||
raise McpOAuthError(flow.error)
|
||||
if state in self._states:
|
||||
flow.error = "The MCP server reused an OAuth state value."
|
||||
raise McpOAuthError(flow.error)
|
||||
flow.authorization_url = authorization_url
|
||||
flow.state = state
|
||||
self._states[state] = flow.flow_id
|
||||
flow.authorization_ready.set()
|
||||
|
||||
async def _wait_for_callback(self, flow: _McpOAuthFlow) -> tuple[str, str | None]:
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is None:
|
||||
raise _OAuthCallbackError("MCP OAuth callback is unavailable")
|
||||
remaining = max(0.1, flow.expires_at - time.monotonic())
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(callback_result), timeout=remaining)
|
||||
except asyncio.TimeoutError as exc:
|
||||
flow.error = "MCP authorization timed out."
|
||||
raise _OAuthCallbackError(flow.error) from exc
|
||||
|
||||
async def _connect(self, flow: _McpOAuthFlow, handlers: MCPOAuthHandlers) -> bool:
|
||||
connections: dict[str, MCPConnection] = {}
|
||||
try:
|
||||
connections = await connect_mcp_servers(
|
||||
{flow.name: flow.cfg},
|
||||
ToolRegistry(),
|
||||
oauth_handlers={flow.name: handlers},
|
||||
)
|
||||
succeeded = flow.name in connections
|
||||
if not succeeded and flow.error is None:
|
||||
flow.error = "Could not complete the MCP OAuth connection."
|
||||
return succeeded
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if flow.error is None:
|
||||
flow.error = "Could not complete the MCP OAuth connection."
|
||||
return False
|
||||
finally:
|
||||
for connection in connections.values():
|
||||
with suppress(Exception):
|
||||
await connection.aclose()
|
||||
|
||||
async def _connect_and_reload(
|
||||
self,
|
||||
flow: _McpOAuthFlow,
|
||||
handlers: MCPOAuthHandlers,
|
||||
reload_mcp: McpReload,
|
||||
) -> bool:
|
||||
succeeded = await self._connect(flow, handlers)
|
||||
if not succeeded:
|
||||
return False
|
||||
try:
|
||||
flow.reload_result = await reload_mcp()
|
||||
failed = flow.reload_result.get("failed")
|
||||
if (
|
||||
not flow.reload_result.get("ok")
|
||||
and not flow.reload_result.get("requires_restart")
|
||||
and isinstance(failed, list)
|
||||
and flow.name in failed
|
||||
):
|
||||
flow.reload_result = await reload_mcp()
|
||||
except Exception:
|
||||
flow.reload_result = {
|
||||
"ok": False,
|
||||
"message": "Signed in, but nanobot could not activate the MCP tools.",
|
||||
"requires_restart": True,
|
||||
}
|
||||
return True
|
||||
|
||||
def _flow(self, flow_id: str) -> _McpOAuthFlow:
|
||||
flow = self._flows.get(flow_id)
|
||||
if flow is None:
|
||||
raise McpOAuthError("Unknown or expired MCP OAuth flow", status=404)
|
||||
return flow
|
||||
|
||||
def _payload(self, flow: _McpOAuthFlow) -> dict[str, Any]:
|
||||
task = flow.task
|
||||
connected = flow.reload_result.get("connected") if flow.reload_result is not None else None
|
||||
if task is not None and task.cancelled():
|
||||
status = "cancelled"
|
||||
elif task is not None and task.done():
|
||||
try:
|
||||
succeeded = task.result()
|
||||
except Exception:
|
||||
succeeded = False
|
||||
if not succeeded:
|
||||
status = "failed"
|
||||
elif flow.reload_result is None:
|
||||
status = "authorized"
|
||||
elif flow.reload_result.get("ok") or (
|
||||
isinstance(connected, list) and flow.name in connected
|
||||
):
|
||||
status = "connected"
|
||||
else:
|
||||
status = "authorized"
|
||||
elif flow.callback_received:
|
||||
status = "connecting"
|
||||
elif flow.authorization_url:
|
||||
status = "authorization_required"
|
||||
else:
|
||||
status = "starting"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"flow_id": flow.flow_id,
|
||||
"name": flow.name,
|
||||
"status": status,
|
||||
"expires_in": max(0, int(flow.expires_at - time.monotonic())),
|
||||
}
|
||||
if flow.manual_callback:
|
||||
payload["completion_input"] = "callback_url"
|
||||
if flow.authorization_url and status == "authorization_required":
|
||||
payload["authorization_url"] = flow.authorization_url
|
||||
if flow.error:
|
||||
payload["error"] = flow.error
|
||||
if flow.reload_result is not None:
|
||||
payload["hot_reload"] = flow.reload_result
|
||||
return payload
|
||||
|
||||
async def _cancel_name(self, name: str) -> None:
|
||||
for flow in list(self._flows.values()):
|
||||
if flow.name == name and flow.task is not None and not flow.task.done():
|
||||
await self._cancel_flow(flow)
|
||||
|
||||
async def _cancel_flow(self, flow: _McpOAuthFlow) -> None:
|
||||
if flow.state:
|
||||
self._states.pop(flow.state, None)
|
||||
task = flow.task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with suppress(BaseException):
|
||||
await task
|
||||
|
||||
def _prune(self) -> None:
|
||||
now = time.monotonic()
|
||||
for flow_id, flow in list(self._flows.items()):
|
||||
if flow.expires_at > now:
|
||||
continue
|
||||
if flow.state:
|
||||
self._states.pop(flow.state, None)
|
||||
if flow.task is not None and not flow.task.done():
|
||||
flow.task.cancel()
|
||||
callback_result = flow.callback_result
|
||||
if callback_result is not None and not callback_result.done():
|
||||
callback_result.cancel()
|
||||
self._flows.pop(flow_id, None)
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -17,15 +16,6 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||
|
||||
from nanobot.agent.plugins import (
|
||||
AgentPluginState,
|
||||
discover_agent_plugin_states,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
delete_mcp_oauth_credentials,
|
||||
mcp_oauth_has_credentials,
|
||||
)
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||
@@ -61,6 +51,7 @@ _MAX_TEST_TOOLS = 16
|
||||
_DEFAULT_TEST_TIMEOUT = 20
|
||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||
|
||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
@@ -346,63 +337,6 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
|
||||
),
|
||||
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
|
||||
),
|
||||
McpPreset(
|
||||
name="xmind",
|
||||
display_name="Xmind",
|
||||
category="productivity",
|
||||
description="Create, read, and edit cloud mind maps through Xmind.",
|
||||
docs_url="https://xmind.com/user-guide/xmind-mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="xmind.com",
|
||||
brand_color="#F4B41A",
|
||||
requires="Xmind account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://app.xmind.com/api/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Xmind OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="notion",
|
||||
display_name="Notion",
|
||||
category="productivity",
|
||||
description="Read and update your Notion workspace through Notion MCP.",
|
||||
docs_url="https://developers.notion.com/guides/mcp/get-started-with-mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="notion.so",
|
||||
brand_color="#111111",
|
||||
requires="Notion account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.notion.com/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Notion OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="linear",
|
||||
display_name="Linear",
|
||||
category="productivity",
|
||||
description="Find and manage Linear issues, projects, and comments.",
|
||||
docs_url="https://linear.app/docs/mcp",
|
||||
transport="streamableHttp",
|
||||
install_supported=True,
|
||||
brand_domain="linear.app",
|
||||
brand_color="#5E6AD2",
|
||||
requires="Linear account",
|
||||
server=MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.linear.app/mcp",
|
||||
tool_timeout=60,
|
||||
),
|
||||
note="Connects securely in your browser with Linear OAuth.",
|
||||
),
|
||||
McpPreset(
|
||||
name="github",
|
||||
display_name="GitHub",
|
||||
@@ -723,8 +657,6 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
|
||||
return "not_installed" if preset.install_supported else "coming_soon"
|
||||
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
|
||||
return "missing_credentials"
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(preset.name, cfg.url):
|
||||
return "authorization_required"
|
||||
if cfg.command and not _command_available(cfg.command):
|
||||
return "missing_dependency"
|
||||
return "configured"
|
||||
@@ -770,7 +702,6 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
|
||||
compact_dict({
|
||||
"type": "mcp",
|
||||
"transport": preset.transport,
|
||||
"auth": server.auth if server and server.auth else None,
|
||||
"command": server.command if server and server.command else None,
|
||||
"args": list(server.args) if server and server.command else None,
|
||||
"url": _connection_summary(server) if server and server.url else None,
|
||||
@@ -821,7 +752,6 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
||||
compact_dict({
|
||||
"type": "mcp",
|
||||
"transport": transport,
|
||||
"auth": cfg.auth,
|
||||
"command": cfg.command or None,
|
||||
"url": _connection_summary(cfg) if cfg.url else None,
|
||||
})
|
||||
@@ -849,7 +779,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
||||
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
||||
cfg = configured_servers.get(preset.name)
|
||||
status = _status_for(preset, cfg)
|
||||
configured = cfg is not None and status not in {"missing_credentials", "authorization_required"}
|
||||
configured = cfg is not None and status not in {"missing_credentials"}
|
||||
logo_url = _favicon_url(preset.brand_domain)
|
||||
return {
|
||||
"name": preset.name,
|
||||
@@ -858,7 +788,6 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
|
||||
"description": preset.description,
|
||||
"docs_url": preset.docs_url,
|
||||
"transport": preset.transport,
|
||||
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
|
||||
"requires": preset.requires,
|
||||
"note": preset.note,
|
||||
"install_supported": preset.install_supported,
|
||||
@@ -885,11 +814,7 @@ def _custom_payload(
|
||||
transport = cfg.type
|
||||
if not transport:
|
||||
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
||||
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url):
|
||||
status = "authorization_required"
|
||||
else:
|
||||
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
|
||||
configured = status != "authorization_required"
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": name,
|
||||
@@ -897,13 +822,12 @@ def _custom_payload(
|
||||
"description": "Custom MCP server from nanobot config.",
|
||||
"docs_url": "",
|
||||
"transport": transport,
|
||||
"auth": cfg.auth,
|
||||
"requires": "",
|
||||
"note": "",
|
||||
"install_supported": True,
|
||||
"installed": True,
|
||||
"configured": configured,
|
||||
"available": configured and _config_available(cfg),
|
||||
"configured": True,
|
||||
"available": _config_available(cfg),
|
||||
"status": status,
|
||||
"logo_url": None,
|
||||
"brand_color": "#64748B",
|
||||
@@ -916,43 +840,6 @@ def _custom_payload(
|
||||
}
|
||||
|
||||
|
||||
def _plugin_logo_data_url(path: Path | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
image_format = path.suffix.lower().lstrip(".").replace("jpg", "jpeg")
|
||||
return f"data:image/{image_format};base64,{encoded}"
|
||||
|
||||
|
||||
def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
|
||||
plugin = state.plugin
|
||||
return {
|
||||
"name": f"plugin-{plugin.name}",
|
||||
"display_name": plugin.display_name,
|
||||
"category": plugin.category,
|
||||
"description": plugin.description or "Agent Plugin",
|
||||
"docs_url": plugin.repository,
|
||||
"transport": "stdio",
|
||||
"requires": ", ".join(plugin.permissions),
|
||||
"note": "",
|
||||
"install_supported": False,
|
||||
"installed": True,
|
||||
"configured": not state.setup_required,
|
||||
"enabled": state.enabled,
|
||||
"available": state.enabled,
|
||||
"status": "enabled" if state.enabled else "disabled",
|
||||
"logo_url": _plugin_logo_data_url(plugin.logo),
|
||||
"brand_color": plugin.accent_color,
|
||||
"required_fields": [],
|
||||
"connection_summary": ", ".join(state.mcp_servers),
|
||||
"source": "agent-plugin",
|
||||
}
|
||||
|
||||
|
||||
def mcp_presets_payload(
|
||||
*,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
@@ -971,17 +858,9 @@ def mcp_presets_payload(
|
||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||
if name not in known
|
||||
]
|
||||
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||
plugin_rows = [
|
||||
_agent_plugin_payload(state)
|
||||
for state in discover_agent_plugin_states(config.workspace_path)
|
||||
if (state.mcp_servers or state.plugin.install_command)
|
||||
and f"plugin-{state.plugin.name}" not in existing_names
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||
"installed_count": len(config.tools.mcp_servers)
|
||||
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||
"presets": [*preset_rows, *custom_rows],
|
||||
"installed_count": len(config.tools.mcp_servers),
|
||||
}
|
||||
if last_action is not None:
|
||||
payload["last_action"] = last_action
|
||||
@@ -1248,32 +1127,6 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
|
||||
return normalized # type: ignore[return-value]
|
||||
|
||||
|
||||
def _normalize_auth(
|
||||
value: object,
|
||||
*,
|
||||
transport: Literal["stdio", "sse", "streamableHttp"],
|
||||
url: str,
|
||||
headers: Mapping[str, str],
|
||||
) -> Literal["oauth"] | None:
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw and url and not headers:
|
||||
normalized_url = url.rstrip("/")
|
||||
if any(
|
||||
preset.server is not None
|
||||
and preset.server.auth == "oauth"
|
||||
and preset.server.url.rstrip("/") == normalized_url
|
||||
for preset in MCP_PRESETS
|
||||
):
|
||||
raw = "oauth"
|
||||
if not raw:
|
||||
return None
|
||||
if raw != "oauth":
|
||||
raise McpPresetError("unsupported MCP auth type")
|
||||
if transport == "stdio":
|
||||
raise McpPresetError("MCP OAuth requires a remote HTTP transport")
|
||||
return "oauth"
|
||||
|
||||
|
||||
def _validated_server_name(name: str) -> str:
|
||||
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
||||
raise McpPresetError("invalid MCP server name")
|
||||
@@ -1289,13 +1142,6 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
||||
raise McpPresetError("stdio MCP servers require a command")
|
||||
if transport in {"sse", "streamableHttp"} and not url:
|
||||
raise McpPresetError("remote MCP servers require a URL")
|
||||
headers = _parse_string_map(_query_first(query, "headers"))
|
||||
auth = _normalize_auth(
|
||||
_query_first(query, "auth"),
|
||||
transport=transport,
|
||||
url=url,
|
||||
headers=headers,
|
||||
)
|
||||
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
|
||||
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
||||
if raw_timeout:
|
||||
@@ -1305,13 +1151,12 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
||||
raise McpPresetError("tool_timeout must be an integer") from exc
|
||||
cfg = MCPServerConfig(
|
||||
type=transport,
|
||||
auth=auth,
|
||||
command=command if transport == "stdio" else "",
|
||||
args=_parse_string_list(_query_first(query, "args")),
|
||||
env=_parse_string_map(_query_first(query, "env")),
|
||||
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||
headers=headers,
|
||||
headers=_parse_string_map(_query_first(query, "headers")),
|
||||
tool_timeout=tool_timeout,
|
||||
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
||||
)
|
||||
@@ -1356,13 +1201,6 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
||||
headers = cast(dict[object, object], headers_value)
|
||||
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
||||
raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
|
||||
typed_headers = cast(dict[str, str], headers)
|
||||
auth = _normalize_auth(
|
||||
server.get("auth"),
|
||||
transport=transport,
|
||||
url=url,
|
||||
headers=typed_headers,
|
||||
)
|
||||
if not isinstance(enabled_tools_value, list):
|
||||
enabled_tools_value = ["*"]
|
||||
else:
|
||||
@@ -1371,13 +1209,12 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
||||
enabled_tools_value = ["*"]
|
||||
return server_name, MCPServerConfig(
|
||||
type=transport,
|
||||
auth=auth,
|
||||
command=command if transport == "stdio" else "",
|
||||
args=cast(list[str], args),
|
||||
env=cast(dict[str, str], env),
|
||||
cwd=cwd if transport == "stdio" else "",
|
||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||
headers=typed_headers,
|
||||
headers=cast(dict[str, str], headers),
|
||||
tool_timeout=timeout_int,
|
||||
enabled_tools=cast(list[str], enabled_tools_value),
|
||||
)
|
||||
@@ -1402,15 +1239,6 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
||||
return out
|
||||
|
||||
|
||||
def _oauth_credentials_replaced(
|
||||
previous: MCPServerConfig | None,
|
||||
replacement: MCPServerConfig,
|
||||
) -> bool:
|
||||
if previous is None or previous.auth != "oauth":
|
||||
return False
|
||||
return replacement.auth != "oauth" or replacement.url != previous.url
|
||||
|
||||
|
||||
def custom_mcp_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
@@ -1420,11 +1248,8 @@ def custom_mcp_action(
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
if action == "custom":
|
||||
name, cfg = _custom_server_from_query(query)
|
||||
delete_credentials = _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config, config_path)
|
||||
if delete_credentials:
|
||||
delete_mcp_oauth_credentials(name)
|
||||
payload = mcp_presets_payload(
|
||||
last_action=_server_action_message(action, name),
|
||||
config_path=config_path,
|
||||
@@ -1434,15 +1259,8 @@ def custom_mcp_action(
|
||||
|
||||
if action in {"import", "import-cursor"}:
|
||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||
delete_credentials = [
|
||||
name
|
||||
for name, cfg in servers.items()
|
||||
if _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||
]
|
||||
config.tools.mcp_servers.update(servers)
|
||||
save_config(config, config_path)
|
||||
for name in delete_credentials:
|
||||
delete_mcp_oauth_credentials(name)
|
||||
payload = mcp_presets_payload(
|
||||
last_action={
|
||||
"ok": True,
|
||||
@@ -1471,27 +1289,6 @@ def custom_mcp_action(
|
||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
||||
|
||||
|
||||
def ensure_mcp_oauth_server(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> tuple[str, MCPServerConfig]:
|
||||
"""Materialize an OAuth preset on first click and return its saved config."""
|
||||
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
||||
config = load_config(config_path) if config_path is not None else load_config()
|
||||
cfg = config.tools.mcp_servers.get(name)
|
||||
if cfg is None:
|
||||
preset = _preset_by_name(name)
|
||||
if preset.server is None or preset.server.auth != "oauth":
|
||||
raise McpPresetError("MCP server does not support browser authorization", status=409)
|
||||
cfg = _materialize_server(preset, query, None)
|
||||
config.tools.mcp_servers[name] = cfg
|
||||
save_config(config, config_path)
|
||||
if cfg.auth != "oauth" or cfg.type not in {"sse", "streamableHttp"} or not cfg.url:
|
||||
raise McpPresetError("MCP server is not configured for OAuth", status=409)
|
||||
return name, cfg
|
||||
|
||||
|
||||
def mcp_presets_action(
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
@@ -1531,7 +1328,6 @@ def mcp_presets_action(
|
||||
cleanup_error = str(exc)
|
||||
del config.tools.mcp_servers[name]
|
||||
save_config(config, config_path)
|
||||
delete_mcp_oauth_credentials(name)
|
||||
last_action = (
|
||||
_action_message(action, preset)
|
||||
if preset is not None
|
||||
@@ -1587,49 +1383,11 @@ async def mcp_presets_settings_action(
|
||||
*,
|
||||
reload_mcp: McpReload | None = None,
|
||||
config: WebUISettingsConfig | None = None,
|
||||
remote: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||
config_path = config.path if config is not None else None
|
||||
if action is None:
|
||||
return mcp_presets_payload(config_path=config_path)
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if name.startswith("plugin-"):
|
||||
plugin_config = load_config(config_path) if config_path is not None else load_config()
|
||||
plugin_name = name.removeprefix("plugin-")
|
||||
plugin_states = discover_agent_plugin_states(plugin_config.workspace_path)
|
||||
plugin_state = next((state for state in plugin_states if state.plugin.name == plugin_name), None)
|
||||
if (
|
||||
name not in plugin_config.tools.mcp_servers
|
||||
and plugin_state is not None
|
||||
and (plugin_state.mcp_servers or plugin_state.plugin.install_command)
|
||||
):
|
||||
if action not in {"enable", "disable"}:
|
||||
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||
if (
|
||||
action == "enable"
|
||||
and plugin_state.setup_required
|
||||
and remote
|
||||
and not plugin_config.tools.webui_allow_remote_package_install
|
||||
):
|
||||
raise McpPresetError(
|
||||
"Agent Plugin setup is restricted to the local WebUI",
|
||||
status=403,
|
||||
)
|
||||
plugin = await asyncio.to_thread(
|
||||
set_agent_plugin_enabled,
|
||||
plugin_config.workspace_path,
|
||||
plugin_name,
|
||||
action == "enable",
|
||||
)
|
||||
verb = "enabled" if action == "enable" else "disabled"
|
||||
payload = mcp_presets_payload(
|
||||
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."},
|
||||
config_path=config_path,
|
||||
)
|
||||
if reload_mcp is not None:
|
||||
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||
return payload
|
||||
if action == "test":
|
||||
return await mcp_presets_test_action(query, config_path=config_path)
|
||||
if config is not None:
|
||||
|
||||
@@ -8,7 +8,6 @@ request mapping and response shaping.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import inspect
|
||||
import json
|
||||
import time
|
||||
@@ -20,7 +19,6 @@ from websockets.http11 import Response
|
||||
|
||||
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels._setup import channel_setup_spec
|
||||
@@ -41,11 +39,9 @@ from nanobot.optional_features import (
|
||||
)
|
||||
from nanobot.pairing import approve_code, deny_code, list_pending
|
||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||
from nanobot.webui.http_utils import http_response as _http_response
|
||||
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
||||
from nanobot.webui.http_utils import query_first as _query_first
|
||||
from nanobot.webui.mcp_oauth_api import McpOAuthManager
|
||||
from nanobot.webui.mcp_presets_api import ensure_mcp_oauth_server, mcp_presets_settings_action
|
||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||
from nanobot.webui.nanobot_features_api import (
|
||||
nanobot_feature_instance_target,
|
||||
nanobot_features_action,
|
||||
@@ -84,7 +80,6 @@ _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||
|
||||
_SKIP_FIELD = object()
|
||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||
_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024
|
||||
|
||||
|
||||
def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
||||
@@ -99,7 +94,6 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
||||
|
||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/enable": "enable",
|
||||
"/api/settings/mcp-presets/disable": "disable",
|
||||
"/api/settings/mcp-presets/remove": "remove",
|
||||
"/api/settings/mcp-presets/test": "test",
|
||||
"/api/settings/mcp-presets/custom": "custom",
|
||||
@@ -136,9 +130,6 @@ _SETTINGS_MUTATION_PATHS = frozenset({
|
||||
"/api/settings/channels/configure",
|
||||
"/api/settings/pairing/approve",
|
||||
"/api/settings/pairing/deny",
|
||||
"/api/settings/mcp-oauth/start",
|
||||
"/api/settings/mcp-oauth/complete",
|
||||
"/api/settings/mcp-oauth/cancel",
|
||||
*_MCP_PRESET_ACTIONS_BY_PATH,
|
||||
})
|
||||
|
||||
@@ -186,7 +177,6 @@ class WebUISettingsRouter:
|
||||
runtime_capabilities: dict[str, Any],
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.bus = bus
|
||||
@@ -199,8 +189,6 @@ class WebUISettingsRouter:
|
||||
self._runtime_capabilities = runtime_capabilities
|
||||
self._channel_feature_action = channel_feature_action
|
||||
self._channel_runtime_status = channel_runtime_status
|
||||
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
|
||||
self._mcp_oauth = McpOAuthManager()
|
||||
self._restart_sections: set[str] = set()
|
||||
self._channel_connectors: dict[str, Any] = {}
|
||||
|
||||
@@ -214,8 +202,6 @@ class WebUISettingsRouter:
|
||||
405,
|
||||
"WebUI mutations require an authenticated WebSocket",
|
||||
)
|
||||
if path == MCP_OAUTH_CALLBACK_PATH:
|
||||
return self._handle_mcp_oauth_callback(request)
|
||||
if path == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
if path == "/api/settings/usage":
|
||||
@@ -294,20 +280,12 @@ class WebUISettingsRouter:
|
||||
if path == "/api/settings/pairing/deny":
|
||||
return self._handle_settings_pairing_action(request, "deny")
|
||||
if path == "/api/settings/mcp-presets":
|
||||
return await self._handle_settings_mcp_presets(connection, request)
|
||||
if path == "/api/settings/mcp-oauth/start":
|
||||
return await self._handle_mcp_oauth_start(request)
|
||||
if path == "/api/settings/mcp-oauth/status":
|
||||
return await self._handle_mcp_oauth_status(request)
|
||||
if path == "/api/settings/mcp-oauth/complete":
|
||||
return self._handle_mcp_oauth_complete(request)
|
||||
if path == "/api/settings/mcp-oauth/cancel":
|
||||
return await self._handle_mcp_oauth_cancel(request)
|
||||
return await self._handle_settings_mcp_presets(request)
|
||||
if path == "/api/settings/version-check":
|
||||
return await self._handle_settings_version_check(request)
|
||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||
if mcp_action is not None:
|
||||
return await self._handle_settings_mcp_presets(connection, request, mcp_action)
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -1230,20 +1208,17 @@ class WebUISettingsRouter:
|
||||
|
||||
async def _handle_settings_mcp_presets(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
action: str | None = None,
|
||||
) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
query = self._parse_mcp_settings_query(request)
|
||||
payload = await mcp_presets_settings_action(
|
||||
action,
|
||||
query,
|
||||
self._parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
config=self.settings.config,
|
||||
remote=not _is_local_browser_request(connection, request.headers),
|
||||
)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
@@ -1255,147 +1230,6 @@ class WebUISettingsRouter:
|
||||
return self._json_response(payload)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
|
||||
async def _handle_mcp_oauth_start(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
if self._mcp_oauth_redirect_uri is None:
|
||||
return self._error_response(500, "MCP OAuth callback is not configured")
|
||||
query = self._parse_mcp_settings_query(request)
|
||||
try:
|
||||
name, cfg = await asyncio.to_thread(
|
||||
self.settings.mutate,
|
||||
ensure_mcp_oauth_server,
|
||||
query,
|
||||
)
|
||||
redirect_uri = self._mcp_oauth_redirect_uri(request)
|
||||
reset = (_query_first(query, "reset") or "").lower() in {"1", "true", "yes"}
|
||||
payload = await self._mcp_oauth.start(
|
||||
name,
|
||||
cfg,
|
||||
redirect_uri,
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
reset_credentials=reset,
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._mcp_oauth_error_response(exc, action="start")
|
||||
return self._json_response(payload)
|
||||
|
||||
async def _handle_mcp_oauth_status(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
flow_id = (_query_first(self._query(request), "flow_id") or "").strip()
|
||||
if not flow_id:
|
||||
return self._error_response(400, "missing MCP OAuth flow ID")
|
||||
try:
|
||||
payload = await self._mcp_oauth.status(flow_id)
|
||||
except Exception as exc:
|
||||
return self._mcp_oauth_error_response(exc, action="status")
|
||||
return self._json_response(payload)
|
||||
|
||||
def _handle_mcp_oauth_complete(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
query = self._query(request)
|
||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||
if not flow_id:
|
||||
return self._error_response(400, "missing MCP OAuth flow ID")
|
||||
callback_url = (_query_first(query, "callback_url") or "").strip()
|
||||
if not callback_url:
|
||||
return self._error_response(400, "Paste the complete callback URL to continue")
|
||||
if len(callback_url.encode("utf-8")) > _MCP_OAUTH_CALLBACK_URL_MAX_BYTES:
|
||||
return self._error_response(400, "The MCP OAuth callback URL is too long")
|
||||
try:
|
||||
payload = self._mcp_oauth.submit_callback_url(
|
||||
flow_id=flow_id,
|
||||
callback_url=callback_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._mcp_oauth_error_response(exc, action="complete")
|
||||
return self._json_response(payload)
|
||||
|
||||
async def _handle_mcp_oauth_cancel(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
flow_id = (_query_first(self._query(request), "flow_id") or "").strip()
|
||||
if not flow_id:
|
||||
return self._error_response(400, "missing MCP OAuth flow ID")
|
||||
try:
|
||||
payload = await self._mcp_oauth.cancel(flow_id)
|
||||
except Exception as exc:
|
||||
return self._mcp_oauth_error_response(exc, action="cancel")
|
||||
return self._json_response(payload)
|
||||
|
||||
def _handle_mcp_oauth_callback(self, request: WsRequest) -> Response:
|
||||
query = self._query(request)
|
||||
state = (_query_first(query, "state") or "").strip()
|
||||
if not state:
|
||||
return self._mcp_oauth_callback_page(
|
||||
ok=False,
|
||||
message="This authorization request is missing its security state.",
|
||||
status=400,
|
||||
)
|
||||
try:
|
||||
name = self._mcp_oauth.submit_callback(
|
||||
state=state,
|
||||
code=_query_first(query, "code"),
|
||||
error=_query_first(query, "error"),
|
||||
)
|
||||
except Exception as exc:
|
||||
status = int(getattr(exc, "status", 400))
|
||||
message = str(getattr(exc, "message", "Could not complete MCP authorization"))
|
||||
return self._mcp_oauth_callback_page(ok=False, message=message, status=status)
|
||||
return self._mcp_oauth_callback_page(
|
||||
ok=True,
|
||||
message=f"Authorization received for {name}. Return to nanobot to finish connecting.",
|
||||
)
|
||||
|
||||
def _mcp_oauth_error_response(self, exc: Exception, *, action: str) -> Response:
|
||||
raw_status = getattr(exc, "status", 500)
|
||||
status = raw_status if isinstance(raw_status, int) and 400 <= raw_status <= 599 else 500
|
||||
if status >= 500:
|
||||
self.logger.exception("MCP OAuth '{}' failed", action)
|
||||
message = f"MCP OAuth {action} failed"
|
||||
else:
|
||||
raw_message = getattr(exc, "message", None)
|
||||
message = raw_message if isinstance(raw_message, str) else "MCP OAuth request failed"
|
||||
return self._error_response(status, message)
|
||||
|
||||
@staticmethod
|
||||
def _mcp_oauth_callback_page(
|
||||
*,
|
||||
ok: bool,
|
||||
message: str,
|
||||
status: int = 200,
|
||||
) -> Response:
|
||||
title = "Authorization received" if ok else "Connection failed"
|
||||
safe_title = html.escape(title)
|
||||
safe_message = html.escape(message)
|
||||
close_script = "<script>setTimeout(() => window.close(), 700)</script>" if ok else ""
|
||||
body = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
f"<title>{safe_title}</title><style>"
|
||||
"body{font:16px system-ui;margin:0;min-height:100vh;display:grid;place-items:center;"
|
||||
"background:#f7f7f6;color:#171717}.card{max-width:34rem;margin:2rem;padding:2rem;"
|
||||
"border:1px solid #ddd;border-radius:16px;background:white}h1{font-size:1.35rem}"
|
||||
"p{line-height:1.55;color:#555}</style></head><body><main class='card'>"
|
||||
f"<h1>{safe_title}</h1><p>{safe_message}</p></main>{close_script}</body></html>"
|
||||
).encode("utf-8")
|
||||
return _http_response(
|
||||
body,
|
||||
status=status,
|
||||
content_type="text/html; charset=utf-8",
|
||||
extra_headers=[
|
||||
("Cache-Control", "no-store"),
|
||||
("Referrer-Policy", "no-referrer"),
|
||||
(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; base-uri 'none'; form-action 'none'; "
|
||||
"frame-ancestors 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
async def _handle_settings_version_check(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
|
||||
@@ -17,7 +17,7 @@ import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from loguru import logger
|
||||
from websockets.datastructures import Headers
|
||||
@@ -160,16 +160,12 @@ _WEBUI_MUTATION_PATHS = {
|
||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
|
||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
||||
"settings.mcp.oauth_start": "/api/settings/mcp-oauth/start",
|
||||
"settings.mcp.oauth_complete": "/api/settings/mcp-oauth/complete",
|
||||
"settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel",
|
||||
}
|
||||
|
||||
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
||||
@@ -348,7 +344,6 @@ class GatewayHTTPHandler:
|
||||
runtime_capabilities=self._capabilities,
|
||||
channel_feature_action=channel_feature_action,
|
||||
channel_runtime_status=channel_runtime_status,
|
||||
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||
)
|
||||
|
||||
def workspace_controls_available(self, connection: Any) -> bool:
|
||||
@@ -622,14 +617,6 @@ class GatewayHTTPHandler:
|
||||
expected_path = _normalize_config_path(self.config.path)
|
||||
return f"{scheme}://{host}{expected_path}"
|
||||
|
||||
def _mcp_oauth_redirect_uri(self, request: WsRequest) -> str:
|
||||
"""Derive the browser callback from the same public origin as WebSocket bootstrap."""
|
||||
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||
|
||||
public_ws_url = urlsplit(self._bootstrap_ws_url(request))
|
||||
scheme = "https" if public_ws_url.scheme == "wss" else "http"
|
||||
return urlunsplit((scheme, public_ws_url.netloc, MCP_OAUTH_CALLBACK_PATH, "", ""))
|
||||
|
||||
# -- Session routes -----------------------------------------------------
|
||||
|
||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent import plugins as agent_plugins
|
||||
from nanobot.agent.plugins import (
|
||||
AGENT_PLUGIN_MCP_SCHEMA,
|
||||
AGENT_PLUGIN_SCHEMA,
|
||||
agent_plugin_mcp_servers,
|
||||
discover_agent_plugin_states,
|
||||
enabled_agent_plugin_skills,
|
||||
set_agent_plugin_enabled,
|
||||
)
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_plugins,
|
||||
"get_config_path",
|
||||
lambda: tmp_path / "config" / "config.json",
|
||||
)
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
|
||||
skill = root / "skills" / name
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return skill
|
||||
|
||||
|
||||
def _manifest(name: str, **fields: object) -> dict[str, object]:
|
||||
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
|
||||
|
||||
|
||||
def _write_plugin(
|
||||
workspace: Path,
|
||||
directory: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
manifest: dict[str, object] | None = None,
|
||||
) -> Path:
|
||||
root = workspace / "plugins" / directory
|
||||
root.mkdir(parents=True)
|
||||
payload = manifest or _manifest(name or directory)
|
||||
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _write_mcp(root: Path, servers: dict[str, object], **fields: object) -> None:
|
||||
payload = {"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers, **fields}
|
||||
(root / "mcp.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_setup_plugin(workspace: Path) -> tuple[Path, Path]:
|
||||
plugin = _write_plugin(
|
||||
workspace,
|
||||
"desktop",
|
||||
manifest=_manifest(
|
||||
"desktop",
|
||||
version="1.2.3",
|
||||
extensions={"dev.nanobot": {"installCommand": ["./bin/install"]}},
|
||||
),
|
||||
)
|
||||
executable = plugin / "bin" / "install"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("setup", encoding="utf-8")
|
||||
return plugin, executable
|
||||
|
||||
|
||||
def _loaded_plugin_skills(workspace: Path) -> list[str]:
|
||||
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
|
||||
|
||||
|
||||
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
|
||||
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
|
||||
assert loader.list_skills() == [
|
||||
{
|
||||
"name": "release-notes",
|
||||
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
|
||||
"source": "plugin",
|
||||
}
|
||||
]
|
||||
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
|
||||
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
|
||||
assert "### Agent Plugin skills" in loader.build_skills_summary()
|
||||
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
|
||||
|
||||
|
||||
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
assert loader.list_skills() == []
|
||||
|
||||
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||
_write_skill(plugin, "release-notes")
|
||||
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||
|
||||
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
|
||||
|
||||
shutil.rmtree(plugin)
|
||||
|
||||
assert loader.list_skills() == []
|
||||
assert loader.build_skills_summary() == ""
|
||||
|
||||
|
||||
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||
_write_skill(plugin, "direct")
|
||||
nested = plugin / "skills" / "group" / "nested"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "SKILL.md").write_text(
|
||||
"---\nname: nested\ndescription: Nested skill.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||
|
||||
assert _loaded_plugin_skills(tmp_path) == ["direct"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest",
|
||||
[
|
||||
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
|
||||
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"},
|
||||
],
|
||||
)
|
||||
def test_invalid_agent_plugin_manifest_is_skipped(
|
||||
tmp_path: Path,
|
||||
manifest: dict[str, object],
|
||||
) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
|
||||
_write_skill(plugin, "example")
|
||||
|
||||
assert discover_agent_plugin_states(tmp_path) == []
|
||||
|
||||
|
||||
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(
|
||||
tmp_path,
|
||||
"demo",
|
||||
manifest=_manifest(
|
||||
"demo",
|
||||
futureField=True,
|
||||
author=None,
|
||||
keywords=None,
|
||||
extensions="invalid but non-fatal",
|
||||
),
|
||||
)
|
||||
_write_skill(plugin, "example")
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
|
||||
assert _loaded_plugin_skills(tmp_path) == ["example"]
|
||||
|
||||
|
||||
def test_agent_plugin_discovers_contained_raster_logo(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(
|
||||
tmp_path,
|
||||
"demo",
|
||||
manifest=_manifest(
|
||||
"demo",
|
||||
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||
),
|
||||
)
|
||||
assets = plugin / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
assert discover_agent_plugin_states(tmp_path)[0].plugin.logo == assets / "icon.png"
|
||||
|
||||
|
||||
def test_agent_plugin_logo_cannot_escape_package(tmp_path: Path) -> None:
|
||||
outside = tmp_path / "outside.png"
|
||||
outside.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
plugin = _write_plugin(
|
||||
tmp_path,
|
||||
"demo",
|
||||
manifest=_manifest(
|
||||
"demo",
|
||||
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||
),
|
||||
)
|
||||
assets = plugin / "assets"
|
||||
assets.mkdir()
|
||||
try:
|
||||
(assets / "icon.png").symlink_to(outside)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"file symlink unavailable: {exc}")
|
||||
|
||||
assert discover_agent_plugin_states(tmp_path)[0].plugin.logo is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("skill_name", "frontmatter"),
|
||||
[
|
||||
("wrong-directory", "name: another\ndescription: Mismatch."),
|
||||
("missing-description", "name: missing-description"),
|
||||
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
|
||||
],
|
||||
)
|
||||
def test_invalid_agent_skill_is_skipped(
|
||||
tmp_path: Path,
|
||||
skill_name: str,
|
||||
frontmatter: str,
|
||||
) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo")
|
||||
skill = plugin / "skills" / skill_name
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
|
||||
assert _loaded_plugin_skills(tmp_path) == []
|
||||
|
||||
|
||||
def test_workspace_skill_overrides_plugin_skill(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo")
|
||||
_write_skill(plugin, "shared", description="Plugin version.")
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
workspace_skill = tmp_path / "skills" / "shared"
|
||||
workspace_skill.mkdir(parents=True)
|
||||
(workspace_skill / "SKILL.md").write_text(
|
||||
"---\nname: shared\ndescription: Workspace version.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||
assert "Workspace version" in (loader.load_skill("shared") or "")
|
||||
|
||||
|
||||
def test_disabled_plugin_skill_cannot_shadow_or_inject_builtin_skill(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo")
|
||||
skill = _write_skill(plugin, "shared", description="Plugin version.")
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: shared\ndescription: Plugin version.\nalways: true\n---\n\nPlugin body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin_skill = builtin / "shared"
|
||||
builtin_skill.mkdir(parents=True)
|
||||
(builtin_skill / "SKILL.md").write_text(
|
||||
"---\nname: shared\ndescription: Built-in version.\n---\n\nBuilt-in body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader = SkillsLoader(tmp_path, builtin_skills_dir=builtin)
|
||||
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||
assert "Built-in version" in (loader.load_skill("shared") or "")
|
||||
assert loader.get_always_skills() == []
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
|
||||
assert "Plugin body" in (loader.load_skill("shared") or "")
|
||||
assert loader.get_always_skills() == ["shared"]
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "demo", False)
|
||||
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||
assert "Built-in version" in (loader.load_skill("shared") or "")
|
||||
|
||||
|
||||
def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "demo")
|
||||
outside = tmp_path / "outside"
|
||||
_write_skill(outside, "escaped")
|
||||
skills_root = plugin / "skills"
|
||||
skills_root.mkdir()
|
||||
try:
|
||||
(skills_root / "escaped").symlink_to(
|
||||
outside / "skills" / "escaped",
|
||||
target_is_directory=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||
|
||||
assert _loaded_plugin_skills(tmp_path) == []
|
||||
|
||||
|
||||
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "desktop")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
_write_mcp(
|
||||
plugin,
|
||||
{
|
||||
"desktop": {
|
||||
"type": "stdio",
|
||||
"command": "./bin/server",
|
||||
"args": ["--data", "${PLUGIN_DATA}/state"],
|
||||
"cwd": "${PLUGIN_ROOT}",
|
||||
}
|
||||
},
|
||||
futureField=True,
|
||||
)
|
||||
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
servers = agent_plugin_mcp_servers(tmp_path)
|
||||
server = servers["desktop"]
|
||||
assert server.command == str(executable)
|
||||
assert server.cwd == str(plugin)
|
||||
assert server.env["PLUGIN_ROOT"] == str(plugin)
|
||||
assert server.args[0] == "--data"
|
||||
assert server.args[1].endswith("/state")
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||
|
||||
|
||||
def test_plugin_setup_command_runs_once_per_version(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("NANOBOT_TEST_SECRET", "do-not-inherit")
|
||||
plugin, executable = _write_setup_plugin(tmp_path)
|
||||
calls: list[tuple[tuple[str, ...], dict[str, str]]] = []
|
||||
|
||||
def run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
calls.append((command, cast(dict[str, str], kwargs["env"])))
|
||||
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||
|
||||
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
|
||||
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == (str(executable),)
|
||||
assert calls[0][1]["PLUGIN_ROOT"] == str(plugin)
|
||||
assert "NANOBOT_TEST_SECRET" not in calls[0][1]
|
||||
assert discover_agent_plugin_states(tmp_path)[0].setup_required is False
|
||||
|
||||
|
||||
def test_concurrent_plugin_enable_runs_setup_once(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_, executable = _write_setup_plugin(tmp_path)
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(command)
|
||||
time.sleep(0.1)
|
||||
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||
|
||||
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
|
||||
ready = Barrier(2)
|
||||
|
||||
def enable() -> None:
|
||||
ready.wait()
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [executor.submit(enable) for _ in range(2)]
|
||||
for future in futures:
|
||||
future.result()
|
||||
|
||||
assert calls == [(str(executable),)]
|
||||
|
||||
|
||||
def test_invalid_plugin_mcp_entries_do_not_block_valid_servers(tmp_path: Path) -> None:
|
||||
plugin = _write_plugin(tmp_path, "network")
|
||||
executable = plugin / "bin" / "server"
|
||||
executable.parent.mkdir()
|
||||
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
_write_mcp(
|
||||
plugin,
|
||||
{
|
||||
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
|
||||
"local": {"type": "stdio", "command": "./bin/server"},
|
||||
"escape": {"type": "stdio", "command": "../outside"},
|
||||
},
|
||||
)
|
||||
set_agent_plugin_enabled(tmp_path, "network", True)
|
||||
|
||||
assert list(agent_plugin_mcp_servers(tmp_path)) == ["network"]
|
||||
|
||||
|
||||
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = tmp_path / "config"
|
||||
outside = tmp_path / "outside"
|
||||
config.mkdir()
|
||||
outside.mkdir()
|
||||
try:
|
||||
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||
monkeypatch.setattr(
|
||||
agent_plugins,
|
||||
"get_config_path",
|
||||
lambda: config / "config.json",
|
||||
)
|
||||
_write_plugin(tmp_path, "desktop")
|
||||
|
||||
with pytest.raises(RuntimeError, match="escapes its parent"):
|
||||
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||
@@ -406,52 +406,6 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
notion = MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.notion.test/mcp",
|
||||
)
|
||||
linear = MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.linear.test/mcp",
|
||||
)
|
||||
config.tools.mcp_servers.update({"notion": notion, "linear": linear})
|
||||
save_config(config)
|
||||
|
||||
attempted: list[str] = []
|
||||
|
||||
async def _fake_connect(servers, _registry):
|
||||
attempted.extend(servers)
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
return {"linear": stack}
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
|
||||
lambda name, _url: name == "linear",
|
||||
)
|
||||
loop = _make_loop(tmp_path, mcp_servers={"notion": notion})
|
||||
|
||||
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert attempted == ["linear"]
|
||||
assert result["ok"] is True
|
||||
assert result["failed"] == []
|
||||
assert result["retried"] == []
|
||||
assert result["connected"] == ["linear"]
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_reconnects_after_session_terminated(
|
||||
tmp_path,
|
||||
|
||||
@@ -266,7 +266,6 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["name"] == "beta"
|
||||
assert entries[0]["path"] == str(beta_path)
|
||||
assert loader.load_skill("alpha") is None
|
||||
|
||||
|
||||
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
||||
|
||||
@@ -9,20 +9,9 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent import plugins as agent_plugins
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_plugins,
|
||||
"get_config_path",
|
||||
lambda: tmp_path / "config" / "config.json",
|
||||
)
|
||||
|
||||
|
||||
def _write_cache(path: Path, registry: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
@@ -402,9 +391,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
"_fetch_skill_content",
|
||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||
)
|
||||
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("legacy", encoding="utf-8")
|
||||
|
||||
payload = manager.install("gimp")
|
||||
|
||||
@@ -414,23 +400,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
||||
assert "state_recorded" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
||||
plugin = manager.workspace / "plugins" / "cli-app-gimp"
|
||||
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
|
||||
assert skill.is_file()
|
||||
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "cli-app-gimp",
|
||||
"version": "1.0.0",
|
||||
"description": "Public duplicate entry",
|
||||
}
|
||||
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
|
||||
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
||||
assert [
|
||||
item["name"]
|
||||
for item in SkillsLoader(manager.workspace).list_skills()
|
||||
if item["source"] == "plugin"
|
||||
] == ["cli-app-gimp"]
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_run_argv_logs_command_exit_and_output(
|
||||
@@ -515,7 +487,7 @@ def test_install_records_available_cli_without_reinstalling(
|
||||
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
||||
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
|
||||
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
|
||||
assert skill.is_file()
|
||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||
|
||||
@@ -732,8 +704,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
|
||||
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
|
||||
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
@@ -746,7 +717,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
||||
|
||||
assert payload["last_action"]["ok"] is True
|
||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||
assert not plugin_dir.exists()
|
||||
assert not skill_dir.exists()
|
||||
|
||||
|
||||
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||
@@ -874,47 +845,19 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"source": "public",
|
||||
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
{
|
||||
"name": "gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"source": "harness",
|
||||
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_legacy_underscored_skill_remains_visible_and_removable(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text(
|
||||
"---\nname: cli-app-unimol_tools\ndescription: Legacy Uni-Mol app.\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manager._save_installed(
|
||||
{"unimol_tools": {"entry_point": "cli-anything-unimol-tools", "source": "harness"}}
|
||||
)
|
||||
|
||||
app = {
|
||||
"name": "unimol_tools",
|
||||
"entry_point": "cli-anything-unimol-tools",
|
||||
"install_cmd": "pip install cli-anything-unimol-tools",
|
||||
}
|
||||
|
||||
assert manager._app_payload(app, manager._load_installed())["skill_installed"] is True
|
||||
assert manager.mentioned_installed_apps("use @unimol_tools")[0]["skill"] == (
|
||||
"skills/cli-app-unimol_tools/SKILL.md"
|
||||
)
|
||||
|
||||
manager.remove_skill("unimol_tools")
|
||||
|
||||
assert not legacy.exists()
|
||||
|
||||
|
||||
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||
assert "CLI App Mention: @zoom" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
|
||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||
@@ -58,23 +58,4 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||
assert "CLI App Attachment: @zoom" in joined
|
||||
assert "tool=run_cli_app" in joined
|
||||
assert "entry_point=cli-anything-zoom" in joined
|
||||
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
|
||||
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
|
||||
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
|
||||
legacy.parent.mkdir(parents=True)
|
||||
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
|
||||
|
||||
lines = runtime_lines_for_request(
|
||||
"please use @unimol_tools",
|
||||
{
|
||||
"cli_apps": [{
|
||||
"name": "unimol_tools",
|
||||
"entry_point": "cli-anything-unimol-tools",
|
||||
}],
|
||||
},
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in "\n".join(lines)
|
||||
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
|
||||
|
||||
@@ -133,16 +133,6 @@ class TestEditFileTool:
|
||||
assert "Successfully" in result
|
||||
assert f.read_text() == "hello earth"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identical_replacement_returns_clear_error(self, tool, tmp_path):
|
||||
f = tmp_path / "a.py"
|
||||
f.write_text("hello world", encoding="utf-8")
|
||||
|
||||
result = await tool.execute(path=str(f), old_text="world", new_text="world")
|
||||
|
||||
assert result == "Error: new_text must be different from old_text."
|
||||
assert f.read_text(encoding="utf-8") == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crlf_normalisation(self, tool, tmp_path):
|
||||
f = tmp_path / "crlf.py"
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
from nanobot.agent.tools.mcp_oauth import (
|
||||
MCPAuthorizationRequiredError,
|
||||
MCPOAuthHandlers,
|
||||
MCPOAuthStorage,
|
||||
create_mcp_oauth_auth,
|
||||
delete_mcp_oauth_credentials,
|
||||
mcp_oauth_has_credentials,
|
||||
)
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
|
||||
def _use_data_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp_oauth.get_data_dir", lambda: tmp_path)
|
||||
|
||||
|
||||
def test_mcp_server_config_accepts_explicit_oauth() -> None:
|
||||
config = MCPServerConfig.model_validate({
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
})
|
||||
|
||||
assert config.auth == "oauth"
|
||||
assert config.model_dump(by_alias=True)["auth"] == "oauth"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_storage_isolates_name_and_server_url(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
storage = MCPOAuthStorage("notion-work", "https://mcp.example.com/mcp")
|
||||
tokens = OAuthToken(access_token="access-secret", refresh_token="refresh-secret")
|
||||
client_info = OAuthClientInformationFull(
|
||||
redirect_uris=["https://agent.example/auth/mcp/callback"],
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
)
|
||||
|
||||
await storage.prepare_redirect_uri("https://agent.example/auth/mcp/callback")
|
||||
await storage.set_tokens(tokens)
|
||||
await storage.set_client_info(client_info)
|
||||
|
||||
assert await storage.get_tokens() == tokens
|
||||
assert await storage.get_client_info() == client_info
|
||||
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
|
||||
assert mcp_oauth_has_credentials("notion-work", "https://mcp.example.com/mcp")
|
||||
assert not mcp_oauth_has_credentials("notion-home", "https://mcp.example.com/mcp")
|
||||
assert not mcp_oauth_has_credentials("notion-work", "https://other.example.com/mcp")
|
||||
|
||||
payload = json.loads((tmp_path / "auth" / "mcp.json").read_text(encoding="utf-8"))
|
||||
assert "https://mcp.example.com/mcp" not in str(payload)
|
||||
assert "access-secret" in str(payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changed_redirect_uri_discards_dynamic_registration_but_keeps_tokens(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
storage = MCPOAuthStorage("linear", "https://mcp.linear.example/mcp")
|
||||
await storage.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||
await storage.set_tokens(OAuthToken(access_token="access-secret"))
|
||||
await storage.set_client_info(OAuthClientInformationFull(
|
||||
redirect_uris=["https://old.example/auth/mcp/callback"],
|
||||
client_id="old-client",
|
||||
))
|
||||
|
||||
await storage.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||
|
||||
assert await storage.get_tokens() is not None
|
||||
assert await storage.get_client_info() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_and_delete_credentials_are_scoped_to_one_server(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
first = MCPOAuthStorage("first", "https://mcp.example.com/mcp")
|
||||
second = MCPOAuthStorage("second", "https://mcp.example.com/mcp")
|
||||
await first.set_tokens(OAuthToken(access_token="first-token"))
|
||||
await second.set_tokens(OAuthToken(access_token="second-token"))
|
||||
|
||||
await first.prepare_redirect_uri(
|
||||
"https://agent.example/auth/mcp/callback",
|
||||
reset=True,
|
||||
)
|
||||
|
||||
assert await first.get_tokens() is None
|
||||
assert await second.get_tokens() is not None
|
||||
assert delete_mcp_oauth_credentials("first")
|
||||
assert not delete_mcp_oauth_credentials("first")
|
||||
assert await second.get_tokens() is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_credentials_reject_late_writes_from_stale_oauth_flow(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.linear.example/mcp"
|
||||
stale = MCPOAuthStorage("linear", server_url)
|
||||
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||
|
||||
assert delete_mcp_oauth_credentials("linear")
|
||||
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
|
||||
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||
|
||||
replacement = MCPOAuthStorage("linear", server_url)
|
||||
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||
await stale.set_tokens(OAuthToken(access_token="late-after-replacement"))
|
||||
|
||||
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||
assert await replacement.get_tokens() is None
|
||||
|
||||
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
|
||||
stored = await replacement.get_tokens()
|
||||
assert stored is not None
|
||||
assert stored.access_token == "fresh-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_before_oauth_claim_rejects_late_credential_writes(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.linear.example/mcp"
|
||||
stale = MCPOAuthStorage("linear", server_url)
|
||||
|
||||
assert not delete_mcp_oauth_credentials("linear")
|
||||
with pytest.raises(MCPAuthorizationRequiredError, match="cancelled"):
|
||||
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
|
||||
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||
|
||||
replacement = MCPOAuthStorage("linear", server_url)
|
||||
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
|
||||
assert mcp_oauth_has_credentials("linear", server_url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mcp_oauth_auth_uses_browser_handlers_and_persists_redirect(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
|
||||
async def redirect(_url: str) -> None:
|
||||
return None
|
||||
|
||||
async def callback() -> tuple[str, str | None]:
|
||||
return "code", "state"
|
||||
|
||||
handlers = MCPOAuthHandlers(
|
||||
redirect_uri="https://agent.example/auth/mcp/callback",
|
||||
redirect_handler=redirect,
|
||||
callback_handler=callback,
|
||||
)
|
||||
|
||||
auth = await create_mcp_oauth_auth(
|
||||
"xmind",
|
||||
"https://app.xmind.example/api/mcp",
|
||||
handlers,
|
||||
)
|
||||
|
||||
assert str(auth.context.client_metadata.redirect_uris[0]) == (
|
||||
"https://agent.example/auth/mcp/callback"
|
||||
)
|
||||
assert str(auth.context.client_metadata.client_uri) == "https://github.com/HKUDS/nanobot"
|
||||
assert str(auth.context.client_metadata.logo_uri) == (
|
||||
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
|
||||
"webui/public/brand/nanobot_apple_touch.png"
|
||||
)
|
||||
assert auth.context.redirect_handler is redirect
|
||||
assert auth.context.callback_handler is callback
|
||||
storage = MCPOAuthStorage("xmind", "https://app.xmind.example/api/mcp")
|
||||
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_authorization_without_tokens_stops_locally(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(MCPAuthorizationRequiredError):
|
||||
await create_mcp_oauth_auth("notion", "https://mcp.notion.example/mcp")
|
||||
|
||||
assert not (tmp_path / "auth" / "mcp.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_authorization_request_clears_rejected_token(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.example.com/mcp"
|
||||
storage = MCPOAuthStorage("notion", server_url)
|
||||
client_info = OAuthClientInformationFull(
|
||||
redirect_uris=["https://agent.example/auth/mcp/callback"],
|
||||
client_id="registered-client",
|
||||
)
|
||||
await storage.set_tokens(OAuthToken(access_token="rejected-token"))
|
||||
await storage.set_client_info(client_info)
|
||||
auth = await create_mcp_oauth_auth("notion", server_url)
|
||||
|
||||
redirect_handler = auth.context.redirect_handler
|
||||
assert redirect_handler is not None
|
||||
with pytest.raises(MCPAuthorizationRequiredError):
|
||||
await redirect_handler("https://accounts.example.com/authorize?state=state")
|
||||
|
||||
assert await storage.get_tokens() is None
|
||||
assert await storage.get_client_info() == client_info
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_official_mcp_sdk_completes_discovery_registration_and_token_exchange(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_data_dir(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.example.com/mcp"
|
||||
authorization_url = ""
|
||||
requests: list[tuple[str, str]] = []
|
||||
|
||||
async def redirect(url: str) -> None:
|
||||
nonlocal authorization_url
|
||||
authorization_url = url
|
||||
|
||||
async def callback() -> tuple[str, str | None]:
|
||||
state = parse_qs(urlsplit(authorization_url).query)["state"][0]
|
||||
return "authorization-code", state
|
||||
|
||||
auth = await create_mcp_oauth_auth(
|
||||
"company-mcp",
|
||||
server_url,
|
||||
MCPOAuthHandlers(
|
||||
redirect_uri="https://agent.example/auth/mcp/callback",
|
||||
redirect_handler=redirect,
|
||||
callback_handler=callback,
|
||||
),
|
||||
)
|
||||
|
||||
async def respond(request: httpx.Request) -> httpx.Response:
|
||||
requests.append((request.method, str(request.url)))
|
||||
if str(request.url) == server_url:
|
||||
if request.headers.get("Authorization") == "Bearer access-token":
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
return httpx.Response(
|
||||
401,
|
||||
headers={
|
||||
"WWW-Authenticate": (
|
||||
'Bearer resource_metadata="https://mcp.example.com/'
|
||||
'.well-known/oauth-protected-resource"'
|
||||
)
|
||||
},
|
||||
)
|
||||
if request.url.path == "/.well-known/oauth-protected-resource":
|
||||
return httpx.Response(200, json={
|
||||
"resource": server_url,
|
||||
"authorization_servers": ["https://auth.example.com"],
|
||||
})
|
||||
if request.url.path == "/.well-known/oauth-authorization-server":
|
||||
return httpx.Response(200, json={
|
||||
"issuer": "https://auth.example.com",
|
||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||
"token_endpoint": "https://auth.example.com/token",
|
||||
"registration_endpoint": "https://auth.example.com/register",
|
||||
"response_types_supported": ["code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
})
|
||||
if request.url.path == "/register":
|
||||
registration = json.loads(request.content)
|
||||
assert registration["client_uri"] == "https://github.com/HKUDS/nanobot"
|
||||
assert registration["logo_uri"].endswith(
|
||||
"/webui/public/brand/nanobot_apple_touch.png"
|
||||
)
|
||||
return httpx.Response(201, json={
|
||||
"client_id": "nanobot-client",
|
||||
"redirect_uris": ["https://agent.example/auth/mcp/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
})
|
||||
if request.url.path == "/token":
|
||||
return httpx.Response(200, json={
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond),
|
||||
auth=auth,
|
||||
) as client:
|
||||
response = await client.get(server_url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert urlsplit(authorization_url)._replace(query="").geturl() == (
|
||||
"https://auth.example.com/authorize"
|
||||
)
|
||||
assert ("POST", "https://auth.example.com/register") in requests
|
||||
assert ("POST", "https://auth.example.com/token") in requests
|
||||
stored = await MCPOAuthStorage("company-mcp", server_url).get_tokens()
|
||||
assert stored is not None
|
||||
assert stored.access_token == "access-token"
|
||||
assert stored.refresh_token == "refresh-token"
|
||||
@@ -826,23 +826,19 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
||||
) -> None:
|
||||
messages: list[str] = []
|
||||
|
||||
def _error(message: str, *args: object) -> None:
|
||||
messages.append(message.format(*args))
|
||||
|
||||
@asynccontextmanager
|
||||
async def _broken_stdio_client(_params: object):
|
||||
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
||||
yield # pragma: no cover
|
||||
|
||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
|
||||
sink = mcp_mod.logger.add(
|
||||
lambda message: messages.append(message.record["message"]), level="ERROR"
|
||||
)
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error)
|
||||
|
||||
registry = ToolRegistry()
|
||||
try:
|
||||
stacks = await connect_mcp_servers(
|
||||
{"gh": MCPServerConfig(command="github-mcp")}, registry
|
||||
)
|
||||
finally:
|
||||
mcp_mod.logger.remove(sink)
|
||||
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)
|
||||
|
||||
assert stacks == {}
|
||||
assert messages
|
||||
@@ -851,36 +847,6 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
||||
assert "stderr" in messages[-1]
|
||||
|
||||
|
||||
def test_transient_connection_group_logs_brief_warning_and_debug_trace() -> None:
|
||||
records: list[dict] = []
|
||||
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||||
error = ExceptionGroup("transport failed", [httpx.ConnectError("")])
|
||||
try:
|
||||
mcp_mod._log_mcp_connection_failure("notion", error)
|
||||
finally:
|
||||
mcp_mod.logger.remove(sink)
|
||||
|
||||
warning = next(record for record in records if record["level"].name == "WARNING")
|
||||
debug = next(record for record in records if record["level"].name == "DEBUG")
|
||||
assert warning["exception"] is None
|
||||
assert "transient connection failure" in warning["message"]
|
||||
assert debug["exception"] is not None
|
||||
assert not any(record["level"].name == "ERROR" for record in records)
|
||||
|
||||
|
||||
def test_unexpected_connection_failure_keeps_error_trace() -> None:
|
||||
records: list[dict] = []
|
||||
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||||
try:
|
||||
mcp_mod._log_mcp_connection_failure("notion", RuntimeError("boom"))
|
||||
finally:
|
||||
mcp_mod.logger.remove(sink)
|
||||
|
||||
error = next(record for record in records if record["level"].name == "ERROR")
|
||||
assert error["exception"] is not None
|
||||
assert not any(record["level"].name == "WARNING" for record in records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
@@ -1244,129 +1210,6 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||
assert timeout.pool == 30.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transport", ["sse", "streamableHttp"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_attaches_oauth_to_remote_http_client(
|
||||
transport: str,
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
|
||||
oauth_auth = object()
|
||||
oauth_handlers = object()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _reachable(_url: str) -> bool:
|
||||
return True
|
||||
|
||||
def _validate(_url: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
async def _create_auth(name: str, url: str, handlers: object) -> object:
|
||||
captured.update(name=name, url=url, handlers=handlers)
|
||||
return oauth_auth
|
||||
|
||||
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
|
||||
oauth_mod.MCPAuthorizationRequiredError = RuntimeError # type: ignore[attr-defined]
|
||||
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
captured["client_kwargs"] = kwargs
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
@asynccontextmanager
|
||||
async def _capturing_sse_client(
|
||||
_url: str,
|
||||
httpx_client_factory=None,
|
||||
auth=None,
|
||||
):
|
||||
captured["transport_auth"] = auth
|
||||
yield object(), object()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
||||
assert http_client is not None
|
||||
yield object(), object(), object()
|
||||
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient)
|
||||
monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["mcp.client.streamable_http"],
|
||||
"streamable_http_client",
|
||||
_capturing_streamable_http_client,
|
||||
)
|
||||
|
||||
url = "https://mcp.example.com/sse" if transport == "sse" else "https://mcp.example.com/mcp"
|
||||
registry = ToolRegistry()
|
||||
stacks = await connect_mcp_servers(
|
||||
{"remote": MCPServerConfig(type=transport, url=url, auth="oauth")},
|
||||
registry,
|
||||
oauth_handlers={"remote": oauth_handlers}, # type: ignore[arg-type]
|
||||
)
|
||||
for stack in stacks.values():
|
||||
await stack.aclose()
|
||||
|
||||
assert captured["name"] == "remote"
|
||||
assert captured["url"] == url
|
||||
assert captured["handlers"] is oauth_handlers
|
||||
if transport == "sse":
|
||||
assert captured["transport_auth"] is oauth_auth
|
||||
else:
|
||||
client_kwargs = captured["client_kwargs"]
|
||||
assert isinstance(client_kwargs, dict)
|
||||
assert client_kwargs["auth"] is oauth_auth
|
||||
assert client_kwargs["event_hooks"] == {"request": [mcp_mod._validate_mcp_request_url]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_skips_background_oauth_without_credentials(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class AuthorizationRequiredError(RuntimeError):
|
||||
pass
|
||||
|
||||
async def _create_auth(*_args: object) -> object:
|
||||
raise AuthorizationRequiredError
|
||||
|
||||
probe_called = False
|
||||
|
||||
async def _probe(_url: str) -> bool:
|
||||
nonlocal probe_called
|
||||
probe_called = True
|
||||
return True
|
||||
|
||||
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
|
||||
oauth_mod.MCPAuthorizationRequiredError = AuthorizationRequiredError # type: ignore[attr-defined]
|
||||
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _probe)
|
||||
|
||||
stacks = await connect_mcp_servers(
|
||||
{
|
||||
"remote": MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
url="https://mcp.example.com/mcp",
|
||||
auth="oauth",
|
||||
)
|
||||
},
|
||||
ToolRegistry(),
|
||||
)
|
||||
|
||||
assert stacks == {}
|
||||
assert not probe_called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
from nanobot.webui.mcp_oauth_api import (
|
||||
McpOAuthError,
|
||||
McpOAuthManager,
|
||||
prepare_mcp_oauth_redirect_uri,
|
||||
validate_mcp_oauth_redirect_uri,
|
||||
)
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _config() -> MCPServerConfig:
|
||||
return MCPServerConfig(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_flow_retries_current_server_and_ignores_unrelated_reload_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
connection = _Connection()
|
||||
received: dict[str, object] = {}
|
||||
reload_calls = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (True, ""),
|
||||
)
|
||||
|
||||
async def connect(servers, _registry, *, oauth_handlers):
|
||||
assert set(servers) == {"xmind"}
|
||||
handlers = oauth_handlers["xmind"]
|
||||
await handlers.redirect_handler(
|
||||
"https://accounts.example.com/authorize?client_id=test&state=state-123"
|
||||
)
|
||||
received["callback"] = await handlers.callback_handler()
|
||||
return {"xmind": connection}
|
||||
|
||||
async def reload_mcp() -> dict[str, object]:
|
||||
nonlocal reload_calls
|
||||
reload_calls += 1
|
||||
if reload_calls == 1:
|
||||
return {
|
||||
"ok": False,
|
||||
"requires_restart": False,
|
||||
"failed": ["xmind"],
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"requires_restart": False,
|
||||
"connected": ["xmind"],
|
||||
"failed": ["notion"],
|
||||
"message": "MCP config reloaded, but some servers did not connect: notion",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
|
||||
started = await manager.start(
|
||||
"xmind",
|
||||
_config(),
|
||||
"https://agent.example.com/auth/mcp/callback",
|
||||
reload_mcp=reload_mcp,
|
||||
)
|
||||
|
||||
assert started["status"] == "authorization_required"
|
||||
assert started["authorization_url"].startswith("https://accounts.example.com/authorize?")
|
||||
manager.submit_callback(state="state-123", code="oauth-code", error=None)
|
||||
with pytest.raises(McpOAuthError, match="expired"):
|
||||
manager.submit_callback(state="state-123", code="replayed-code", error=None)
|
||||
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if reload_calls == 2:
|
||||
break
|
||||
assert reload_calls == 2
|
||||
|
||||
for _ in range(10):
|
||||
first, second = await asyncio.gather(
|
||||
manager.status(started["flow_id"]),
|
||||
manager.status(started["flow_id"]),
|
||||
)
|
||||
if first["status"] == "connected":
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert first["status"] == "connected"
|
||||
assert second["status"] == "connected"
|
||||
assert first["hot_reload"]["failed"] == ["notion"]
|
||||
assert received["callback"] == ("oauth-code", "state-123")
|
||||
assert reload_calls == 2
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_http_flow_accepts_a_pasted_loopback_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
connection = _Connection()
|
||||
received: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (True, ""),
|
||||
)
|
||||
|
||||
async def connect(_servers, _registry, *, oauth_handlers):
|
||||
handlers = oauth_handlers["linear"]
|
||||
received["redirect_uri"] = handlers.redirect_uri
|
||||
await handlers.redirect_handler(
|
||||
"https://accounts.example.com/authorize?client_id=test&state=manual-state"
|
||||
)
|
||||
received["callback"] = await handlers.callback_handler()
|
||||
return {"linear": connection}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
|
||||
started = await manager.start(
|
||||
"linear",
|
||||
_config(),
|
||||
"http://192.0.2.10:8765/auth/mcp/callback",
|
||||
reload_mcp=lambda: asyncio.sleep(
|
||||
0,
|
||||
result={"ok": True, "requires_restart": False},
|
||||
),
|
||||
)
|
||||
|
||||
assert started["status"] == "authorization_required"
|
||||
assert started["completion_input"] == "callback_url"
|
||||
assert received["redirect_uri"] == "http://127.0.0.1:8765/auth/mcp/callback"
|
||||
|
||||
with pytest.raises(McpOAuthError, match="complete callback URL"):
|
||||
manager.submit_callback_url(
|
||||
flow_id=started["flow_id"],
|
||||
callback_url=(
|
||||
"http://127.0.0.1:8765/wrong?code=oauth-code&state=manual-state"
|
||||
),
|
||||
)
|
||||
with pytest.raises(McpOAuthError, match="different or expired"):
|
||||
manager.submit_callback_url(
|
||||
flow_id=started["flow_id"],
|
||||
callback_url=(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
"?code=oauth-code&state=other-state"
|
||||
),
|
||||
)
|
||||
|
||||
submitted = manager.submit_callback_url(
|
||||
flow_id=started["flow_id"],
|
||||
callback_url=(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
"?code=oauth-code&state=manual-state"
|
||||
),
|
||||
)
|
||||
assert submitted["status"] == "connecting"
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0)
|
||||
result = await manager.status(started["flow_id"])
|
||||
if result["status"] == "connected":
|
||||
break
|
||||
|
||||
assert result["status"] == "connected"
|
||||
assert result["completion_input"] == "callback_url"
|
||||
assert received["callback"] == ("oauth-code", "manual-state")
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_flow_surfaces_provider_denial_without_callback_description(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (True, ""),
|
||||
)
|
||||
|
||||
async def connect(_servers, _registry, *, oauth_handlers):
|
||||
handlers = oauth_handlers["notion"]
|
||||
await handlers.redirect_handler("https://accounts.example.com/auth?state=deny-state")
|
||||
await handlers.callback_handler()
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
started = await manager.start(
|
||||
"notion",
|
||||
_config(),
|
||||
"https://agent.example.com/auth/mcp/callback",
|
||||
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
|
||||
)
|
||||
|
||||
with pytest.raises(McpOAuthError, match="access_denied"):
|
||||
manager.submit_callback(state="deny-state", code=None, error="access_denied")
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
result = await manager.status(started["flow_id"])
|
||||
if result["status"] == "failed":
|
||||
break
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "Authorization was not completed (access_denied)."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("authorization_url", "url_is_safe", "state"),
|
||||
[
|
||||
("https://127.0.0.1/authorize?state=private-state", False, "private-state"),
|
||||
("http://accounts.example.com/authorize?state=http-state", True, "http-state"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_flow_blocks_unsafe_authorization_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
authorization_url: str,
|
||||
url_is_safe: bool,
|
||||
state: str,
|
||||
) -> None:
|
||||
manager = McpOAuthManager()
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||
lambda _url: (url_is_safe, "private address"),
|
||||
)
|
||||
|
||||
async def connect(_servers, _registry, *, oauth_handlers):
|
||||
await oauth_handlers["linear"].redirect_handler(authorization_url)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||
|
||||
result = await manager.start(
|
||||
"linear",
|
||||
_config(),
|
||||
"https://agent.example.com/auth/mcp/callback",
|
||||
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error"] == "The MCP server returned an unsafe authorization URL."
|
||||
with pytest.raises(McpOAuthError, match="expired"):
|
||||
manager.submit_callback(state=state, code="code", error=None)
|
||||
|
||||
|
||||
def test_redirect_uri_requires_https_except_for_loopback() -> None:
|
||||
assert validate_mcp_oauth_redirect_uri(
|
||||
"https://agent.example.com/auth/mcp/callback"
|
||||
) == "https://agent.example.com/auth/mcp/callback"
|
||||
assert validate_mcp_oauth_redirect_uri(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
) == "http://127.0.0.1:8765/auth/mcp/callback"
|
||||
|
||||
with pytest.raises(McpOAuthError, match="HTTPS or localhost"):
|
||||
validate_mcp_oauth_redirect_uri("http://192.0.2.10/auth/mcp/callback")
|
||||
with pytest.raises(McpOAuthError, match="Invalid"):
|
||||
validate_mcp_oauth_redirect_uri("https://agent.example.com/wrong")
|
||||
|
||||
|
||||
def test_remote_http_redirect_prepares_a_manual_loopback_callback() -> None:
|
||||
assert prepare_mcp_oauth_redirect_uri(
|
||||
"https://agent.example.com/auth/mcp/callback"
|
||||
) == ("https://agent.example.com/auth/mcp/callback", False)
|
||||
assert prepare_mcp_oauth_redirect_uri(
|
||||
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||
) == ("http://127.0.0.1:8765/auth/mcp/callback", False)
|
||||
assert prepare_mcp_oauth_redirect_uri(
|
||||
"http://agent.example.com:9443/auth/mcp/callback"
|
||||
) == ("http://127.0.0.1:9443/auth/mcp/callback", True)
|
||||
@@ -1,78 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp.shared.auth import OAuthToken
|
||||
|
||||
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
McpPresetError,
|
||||
custom_mcp_action,
|
||||
mcp_presets_action,
|
||||
mcp_presets_payload,
|
||||
mcp_presets_settings_action,
|
||||
mcp_presets_test_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
|
||||
|
||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
|
||||
def _write_agent_plugin(workspace: Path) -> None:
|
||||
root = workspace / "plugins" / "desktop"
|
||||
command = root / "bin" / "server"
|
||||
command.parent.mkdir(parents=True, exist_ok=True)
|
||||
command.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
setup = root / "bin" / "install"
|
||||
setup.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
setup.chmod(0o755)
|
||||
assets = root / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||
(root / "plugin.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||
"name": "desktop",
|
||||
"description": "Control the local desktop.",
|
||||
"extensions": {
|
||||
"dev.nanobot": {
|
||||
"displayName": "Desktop Control",
|
||||
"accentColor": "#ff7a1a",
|
||||
"logo": "./assets/icon.png",
|
||||
"permissions": ["screen-recording"],
|
||||
"installCommand": ["./bin/install"],
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||
"mcpServers": {
|
||||
"desktop": {"type": "stdio", "command": "./bin/server"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
||||
|
||||
|
||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -94,9 +38,6 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
||||
"aws-docs",
|
||||
"brave-search",
|
||||
"postman",
|
||||
"xmind",
|
||||
"notion",
|
||||
"linear",
|
||||
}.issubset(names)
|
||||
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
||||
assert browserbase["installed"] is False
|
||||
@@ -114,99 +55,6 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||
|
||||
|
||||
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
_write_agent_plugin(load_config().workspace_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.plugins.subprocess.run",
|
||||
lambda command, **_: subprocess.CompletedProcess(command, 0, "", ""),
|
||||
)
|
||||
|
||||
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||
assert row["name"] == "plugin-desktop"
|
||||
assert row["display_name"] == "Desktop Control"
|
||||
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
||||
assert row["install_supported"] is False
|
||||
assert row["installed"] is True
|
||||
assert row["configured"] is False
|
||||
assert row["enabled"] is False
|
||||
assert row["status"] == "disabled"
|
||||
|
||||
async def reload() -> dict[str, object]:
|
||||
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
|
||||
|
||||
plugin_action = partial(
|
||||
mcp_presets_settings_action,
|
||||
query={"name": ["plugin-desktop"]},
|
||||
)
|
||||
with pytest.raises(McpPresetError, match="restricted") as restricted:
|
||||
asyncio.run(plugin_action("enable", remote=True))
|
||||
assert restricted.value.status == 403
|
||||
|
||||
enabled = asyncio.run(plugin_action("enable", reload_mcp=reload))
|
||||
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
|
||||
assert enabled_row["configured"] is True
|
||||
assert enabled_row["enabled"] is True
|
||||
assert enabled_row["status"] == "enabled"
|
||||
assert enabled["requires_restart"] is False
|
||||
|
||||
disabled = asyncio.run(plugin_action("disable", reload_mcp=reload))
|
||||
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
|
||||
assert disabled_row["installed"] is True
|
||||
assert disabled_row["configured"] is True
|
||||
assert disabled_row["enabled"] is False
|
||||
assert disabled_row["status"] == "disabled"
|
||||
|
||||
with pytest.raises(McpPresetError, match="enable and disable"):
|
||||
asyncio.run(plugin_action("remove"))
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config["tools"] = {
|
||||
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
|
||||
}
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
rows = [
|
||||
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
|
||||
]
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["source"] == "custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = mcp_presets_action("enable", {"name": ["xmind"]})
|
||||
|
||||
row = next(item for item in payload["presets"] if item["name"] == "xmind")
|
||||
assert row["installed"] is True
|
||||
assert row["configured"] is False
|
||||
assert row["status"] == "authorization_required"
|
||||
assert row["transport"] == "streamableHttp"
|
||||
assert row["auth"] == "oauth"
|
||||
config = load_config()
|
||||
cfg = config.tools.mcp_servers["xmind"]
|
||||
assert cfg.type == "streamableHttp"
|
||||
assert cfg.auth == "oauth"
|
||||
assert cfg.url == "https://app.xmind.com/api/mcp"
|
||||
|
||||
await MCPOAuthStorage("xmind", cfg.url).set_tokens(OAuthToken(access_token="secret"))
|
||||
connected = mcp_presets_payload()
|
||||
row = next(item for item in connected["presets"] if item["name"] == "xmind")
|
||||
assert row["configured"] is True
|
||||
assert row["status"] == "configured"
|
||||
|
||||
mcp_presets_action("remove", {"name": ["xmind"]})
|
||||
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
|
||||
|
||||
|
||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -448,11 +296,11 @@ def test_test_mcp_preset_scrubs_connection_errors(
|
||||
assert "<redacted>" in payload["last_action"]["error"]
|
||||
|
||||
|
||||
def test_unknown_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(McpPresetError) as exc:
|
||||
mcp_presets_action("enable", {"name": ["asana"]})
|
||||
mcp_presets_action("enable", {"name": ["linear"]})
|
||||
|
||||
assert exc.value.status == 404
|
||||
|
||||
@@ -566,72 +414,6 @@ def test_import_mcp_config_and_tool_allowlist(
|
||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
||||
|
||||
|
||||
def test_import_recognizes_known_and_explicit_oauth_servers(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = custom_mcp_action(
|
||||
"import",
|
||||
{
|
||||
"config": [
|
||||
(
|
||||
'{"mcpServers":{'
|
||||
'"notion-work":{"url":"https://mcp.notion.com/mcp"},'
|
||||
'"company-mcp":{"url":"https://mcp.example.com/mcp","auth":"oauth"},'
|
||||
'"notion-pat":{"url":"https://mcp.notion.com/mcp",'
|
||||
'"headers":{"Authorization":"Bearer secret"}}'
|
||||
'}}'
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
assert config.tools.mcp_servers["notion-work"].auth == "oauth"
|
||||
assert config.tools.mcp_servers["company-mcp"].auth == "oauth"
|
||||
assert config.tools.mcp_servers["notion-pat"].auth is None
|
||||
rows = {row["name"]: row for row in payload["presets"]}
|
||||
assert rows["notion-work"]["status"] == "authorization_required"
|
||||
assert rows["company-mcp"]["status"] == "authorization_required"
|
||||
assert rows["notion-pat"]["status"] == "configured"
|
||||
assert "Bearer secret" not in str(payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replacing_oauth_config_removes_its_stored_credentials(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
server_url = "https://mcp.example.com/mcp"
|
||||
custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["company-mcp"],
|
||||
"transport": ["streamableHttp"],
|
||||
"url": [server_url],
|
||||
"auth": ["oauth"],
|
||||
},
|
||||
)
|
||||
await MCPOAuthStorage("company-mcp", server_url).set_tokens(
|
||||
OAuthToken(access_token="secret")
|
||||
)
|
||||
assert mcp_oauth_has_credentials("company-mcp", server_url)
|
||||
|
||||
custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["company-mcp"],
|
||||
"transport": ["streamableHttp"],
|
||||
"url": [server_url],
|
||||
},
|
||||
)
|
||||
|
||||
assert not mcp_oauth_has_credentials("company-mcp", server_url)
|
||||
|
||||
|
||||
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
@@ -28,7 +28,6 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
),
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities={},
|
||||
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
|
||||
)
|
||||
|
||||
|
||||
@@ -40,121 +39,6 @@ def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
||||
config = SimpleNamespace(
|
||||
type="streamableHttp",
|
||||
auth="oauth",
|
||||
url="https://app.xmind.com/api/mcp",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.ensure_mcp_oauth_server",
|
||||
lambda _query, *, config_path=None: ("xmind", config),
|
||||
)
|
||||
router = _router()
|
||||
start = AsyncMock(return_value={
|
||||
"status": "authorization_required",
|
||||
"flow_id": "flow-123",
|
||||
"name": "xmind",
|
||||
"authorization_url": "https://xmind.example/authorize?state=state-123",
|
||||
})
|
||||
router._mcp_oauth = SimpleNamespace(start=start)
|
||||
request = _mutation_request(
|
||||
"/api/settings/mcp-oauth/start",
|
||||
{"name": "xmind"},
|
||||
)
|
||||
|
||||
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["flow_id"] == "flow-123"
|
||||
start.assert_awaited_once_with(
|
||||
"xmind",
|
||||
config,
|
||||
"https://gateway.example/auth/mcp/callback",
|
||||
reload_mcp=ANY,
|
||||
reset_credentials=False,
|
||||
)
|
||||
|
||||
denied = _router(authorized=False)
|
||||
denied_response = await denied.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||
assert denied_response is not None
|
||||
assert denied_response.status_code == 401
|
||||
|
||||
failed = _router()
|
||||
failed._mcp_oauth = SimpleNamespace(
|
||||
start=AsyncMock(side_effect=RuntimeError("upstream secret response"))
|
||||
)
|
||||
failed_response = await failed.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||
assert failed_response is not None
|
||||
assert failed_response.status_code == 500
|
||||
assert json.loads(failed_response.body) == {"error": "MCP OAuth start failed"}
|
||||
assert b"upstream secret response" not in failed_response.body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_callback_is_state_authenticated_and_returns_close_page() -> None:
|
||||
router = _router(authorized=False)
|
||||
submit = MagicMock(return_value="xmind")
|
||||
router._mcp_oauth = SimpleNamespace(submit_callback=submit)
|
||||
request = SimpleNamespace(
|
||||
path="/auth/mcp/callback?code=oauth-code&state=state-123",
|
||||
headers=Headers(),
|
||||
)
|
||||
|
||||
response = await router.dispatch(None, request, "/auth/mcp/callback")
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
|
||||
assert b"window.close" in response.body
|
||||
assert b"Authorization received" in response.body
|
||||
assert b"oauth-code" not in response.body
|
||||
submit.assert_called_once_with(state="state-123", code="oauth-code", error=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_oauth_manual_completion_reads_websocket_payload() -> None:
|
||||
callback_url = (
|
||||
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=state-123"
|
||||
)
|
||||
router = _router()
|
||||
submit = MagicMock(
|
||||
return_value={
|
||||
"flow_id": "flow-123",
|
||||
"name": "linear",
|
||||
"status": "connecting",
|
||||
"expires_in": 299,
|
||||
"completion_input": "callback_url",
|
||||
}
|
||||
)
|
||||
router._mcp_oauth = SimpleNamespace(submit_callback_url=submit)
|
||||
request = _mutation_request(
|
||||
"/api/settings/mcp-oauth/complete",
|
||||
{"flow_id": "flow-123", "callback_url": callback_url},
|
||||
)
|
||||
|
||||
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/complete")
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["status"] == "connecting"
|
||||
assert b"oauth-code" not in response.body
|
||||
submit.assert_called_once_with(flow_id="flow-123", callback_url=callback_url)
|
||||
|
||||
denied = _router(authorized=False)
|
||||
denied_response = await denied.dispatch(
|
||||
None,
|
||||
request,
|
||||
"/api/settings/mcp-oauth/complete",
|
||||
)
|
||||
assert denied_response is not None
|
||||
assert denied_response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "authorization_response"),
|
||||
[
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request as WsRequest
|
||||
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||
|
||||
|
||||
def _handler(config: WebSocketConfig) -> GatewayHTTPHandler:
|
||||
handler = object.__new__(GatewayHTTPHandler)
|
||||
handler.config = config
|
||||
return handler
|
||||
|
||||
|
||||
def _request(**headers: str) -> WsRequest:
|
||||
return cast(WsRequest, SimpleNamespace(headers=Headers(headers)))
|
||||
|
||||
|
||||
def test_mcp_oauth_callback_uses_configured_public_websocket_origin() -> None:
|
||||
handler = _handler(WebSocketConfig(path="/ws", public_ws_url="wss://agent.example/ws"))
|
||||
|
||||
redirect_uri = handler._mcp_oauth_redirect_uri(_request(Host="ignored.example"))
|
||||
|
||||
assert redirect_uri == "https://agent.example/auth/mcp/callback"
|
||||
|
||||
|
||||
def test_mcp_oauth_callback_uses_safe_forwarded_request_origin() -> None:
|
||||
handler = _handler(WebSocketConfig(path="/ws", host="127.0.0.1", port=8765))
|
||||
|
||||
redirect_uri = handler._mcp_oauth_redirect_uri(
|
||||
_request(Host="nanobot.example:9443", **{"X-Forwarded-Proto": "https"})
|
||||
)
|
||||
|
||||
assert redirect_uri == "https://nanobot.example:9443/auth/mcp/callback"
|
||||
+356
-35
@@ -12,8 +12,26 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import type { SidebarDeleteItem } from "@/components/ChatList";
|
||||
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||
import {
|
||||
WORKBENCH_STORAGE_KEY,
|
||||
MAX_WORKBENCH_PANES,
|
||||
addWorkbenchPane,
|
||||
attachWorkbenchPane,
|
||||
detachWorkbenchPane,
|
||||
ensureWorkbenchTab,
|
||||
focusWorkbenchPane,
|
||||
parseWorkbenchState,
|
||||
promoteWorkbenchPane,
|
||||
reconcileWorkbench,
|
||||
setWorkbenchLayout,
|
||||
workbenchChildPaneKeys,
|
||||
workbenchTab,
|
||||
type WorkbenchState,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
@@ -120,6 +138,14 @@ const RenameChatDialog = lazy(async () => {
|
||||
return { default: module.RenameChatDialog };
|
||||
});
|
||||
|
||||
function readWorkbenchState(): WorkbenchState {
|
||||
try {
|
||||
return parseWorkbenchState(window.localStorage.getItem(WORKBENCH_STORAGE_KEY));
|
||||
} catch {
|
||||
return parseWorkbenchState(null);
|
||||
}
|
||||
}
|
||||
|
||||
function SurfaceLoadingFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -1034,9 +1060,18 @@ function Shell({
|
||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
||||
const [workbenchState, setWorkbenchState] = useState(readWorkbenchState);
|
||||
const [creatingPane, setCreatingPane] = useState(false);
|
||||
const childPaneKeys = useMemo(
|
||||
() => workbenchChildPaneKeys(workbenchState),
|
||||
[workbenchState],
|
||||
);
|
||||
const topicSessions = useMemo(
|
||||
() => sessions.filter((session) => !childPaneKeys.has(session.key)),
|
||||
[childPaneKeys, sessions],
|
||||
);
|
||||
const [pendingDelete, setPendingDelete] = useState<{
|
||||
key: string;
|
||||
label: string;
|
||||
items: SidebarDeleteItem[];
|
||||
automations?: SessionAutomationJob[];
|
||||
} | null>(null);
|
||||
const [pendingRename, setPendingRename] = useState<{
|
||||
@@ -1157,6 +1192,17 @@ function Shell({
|
||||
}
|
||||
}, [hostSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
WORKBENCH_STORAGE_KEY,
|
||||
JSON.stringify(workbenchState),
|
||||
);
|
||||
} catch {
|
||||
// ignore storage errors (private mode, etc.)
|
||||
}
|
||||
}, [workbenchState]);
|
||||
|
||||
useEffect(() => {
|
||||
writeSessionUpdateChatIds(updatedChatIds);
|
||||
}, [updatedChatIds]);
|
||||
@@ -1220,9 +1266,19 @@ function Shell({
|
||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey, temporarySessions]);
|
||||
const activeTabState = useMemo(() => (
|
||||
activeKey && !temporarySessions[activeKey]
|
||||
? workbenchTab(workbenchState, activeKey)
|
||||
: null
|
||||
), [activeKey, temporarySessions, workbenchState]);
|
||||
const activePaneSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeTabState) return activeSession;
|
||||
return sessions.find((session) => session.key === activeTabState.activePaneKey)
|
||||
?? activeSession;
|
||||
}, [activeSession, activeTabState, sessions]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
const activeChatId = activePaneSession?.chatId ?? null;
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = activeChatId;
|
||||
if (!activeChatId) return;
|
||||
@@ -1242,13 +1298,13 @@ function Shell({
|
||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||
return workspaceOverrides[activeChatId];
|
||||
}
|
||||
if (activeSession?.workspaceScope) {
|
||||
return activeSession.workspaceScope;
|
||||
if (activePaneSession?.workspaceScope) {
|
||||
return activePaneSession.workspaceScope;
|
||||
}
|
||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||
}, [
|
||||
activeChatId,
|
||||
activeSession?.workspaceScope,
|
||||
activePaneSession?.workspaceScope,
|
||||
draftWorkspaceScope,
|
||||
temporaryChatRequested,
|
||||
workspaceOverrides,
|
||||
@@ -1284,6 +1340,18 @@ function Shell({
|
||||
});
|
||||
}, [loading, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const validKeys = new Set(sessions.map((session) => session.key));
|
||||
setWorkbenchState((current) => {
|
||||
const reconciled = reconcileWorkbench(current, validKeys);
|
||||
if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) {
|
||||
return reconciled;
|
||||
}
|
||||
return ensureWorkbenchTab(reconciled, activeKey);
|
||||
});
|
||||
}, [activeKey, loading, sessions, temporarySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||
@@ -1788,7 +1856,7 @@ function Shell({
|
||||
});
|
||||
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
||||
const archived = new Set([...sidebarState.archived_keys, key]);
|
||||
const next = sessions.find((session) => !archived.has(session.key));
|
||||
const next = topicSessions.find((session) => !archived.has(session.key));
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: next?.key ?? null,
|
||||
@@ -1796,7 +1864,7 @@ function Shell({
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
||||
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
|
||||
);
|
||||
|
||||
const onReorderSessions = useCallback(
|
||||
@@ -1825,6 +1893,47 @@ function Shell({
|
||||
setSessionSearchOpen(true);
|
||||
}, []);
|
||||
|
||||
const onAddPane = useCallback(async () => {
|
||||
const tabKey = activeKey;
|
||||
if (
|
||||
!tabKey
|
||||
|| !activeSession
|
||||
|| creatingPane
|
||||
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|
||||
|| temporarySessionsRef.current[tabKey]
|
||||
) return;
|
||||
setMobileSidebarOpen(false);
|
||||
setSessionSearchOpen(false);
|
||||
setCreatingPane(true);
|
||||
try {
|
||||
const scope = activeWorkspaceScope;
|
||||
const chatId = await createChat(scope);
|
||||
const paneKey = `websocket:${chatId}`;
|
||||
setWorkbenchState((current) => addWorkbenchPane(current, tabKey, paneKey));
|
||||
if (scope) {
|
||||
setWorkspaceOverrides((current) => ({
|
||||
...current,
|
||||
[chatId]: normalizeWorkspaceScope(scope),
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create pane", error);
|
||||
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
|
||||
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
||||
}
|
||||
} finally {
|
||||
setCreatingPane(false);
|
||||
}
|
||||
}, [
|
||||
activeKey,
|
||||
activeSession,
|
||||
activeTabState,
|
||||
activeWorkspaceScope,
|
||||
createChat,
|
||||
creatingPane,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
@@ -1902,15 +2011,15 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
const nextKey = (() => {
|
||||
if (!activeKey) return null;
|
||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||
return sessions[0]?.key ?? null;
|
||||
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
||||
return topicSessions[0]?.key ?? null;
|
||||
})();
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: nextKey,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
}, [activeKey, navigate, sessions]);
|
||||
}, [activeKey, navigate, topicSessions]);
|
||||
|
||||
const onRestart = useCallback(() => {
|
||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||
@@ -2017,31 +2126,43 @@ function Shell({
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useDeferredTitleRefresh(
|
||||
temporaryChatActive ? null : activeSession,
|
||||
temporaryChatActive ? null : activePaneSession,
|
||||
refresh,
|
||||
);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
const key = pendingDelete.key;
|
||||
const items = pendingDelete.items;
|
||||
const deletingKeys = new Set(items.map((item) => item.key));
|
||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||
const deletingActive = activeKey === key;
|
||||
const currentIndex = sessions.findIndex((s) => s.key === key);
|
||||
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
||||
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
||||
const fallbackKey = deletingActive
|
||||
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
|
||||
? (
|
||||
topicSessions.slice(currentIndex + 1).find((session) => (
|
||||
!deletingKeys.has(session.key)
|
||||
))?.key
|
||||
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
||||
!deletingKeys.has(session.key)
|
||||
))?.key
|
||||
?? null
|
||||
)
|
||||
: activeKey;
|
||||
try {
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
const result = await deleteChat(
|
||||
key,
|
||||
item.key,
|
||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||
);
|
||||
if (result.blocked_by_automations) {
|
||||
setPendingDelete({
|
||||
...pendingDelete,
|
||||
items: items.slice(index),
|
||||
automations: result.automations ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPendingDelete(null);
|
||||
if (deletingActive) {
|
||||
navigate({
|
||||
@@ -2053,18 +2174,24 @@ function Shell({
|
||||
} catch (e) {
|
||||
console.error("Failed to delete session", e);
|
||||
}
|
||||
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
||||
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
||||
|
||||
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
||||
let automations: SessionAutomationJob[] = [];
|
||||
try {
|
||||
automations = await getSessionAutomations(key);
|
||||
} catch {
|
||||
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
||||
}
|
||||
setPendingDelete({ key, label, automations });
|
||||
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
||||
if (uniqueItems.length === 0) return;
|
||||
const automationResults = await Promise.allSettled(
|
||||
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
||||
);
|
||||
const automations = automationResults.flatMap((result) => (
|
||||
result.status === "fulfilled" ? result.value : []
|
||||
));
|
||||
setPendingDelete({ items: uniqueItems, automations });
|
||||
}, [getSessionAutomations]);
|
||||
|
||||
const onRequestDelete = useCallback((key: string, label: string) => {
|
||||
void onRequestDeleteMany([{ key, label }]);
|
||||
}, [onRequestDeleteMany]);
|
||||
|
||||
const visiblePairingRequests = useMemo(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
@@ -2109,13 +2236,117 @@ function Shell({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const titleForSession = useCallback((session: ChatSummary) => (
|
||||
sidebarState.title_overrides[session.key]
|
||||
|| session.title
|
||||
|| deriveTitle(session.preview, t("chat.newChat"))
|
||||
), [sidebarState.title_overrides, t]);
|
||||
|
||||
const headerTitle = temporaryChatActive
|
||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||
: activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
? titleForSession(activeSession)
|
||||
: t("app.brand");
|
||||
const workbenchPaneSessions = useMemo(() => {
|
||||
if (!activeTabState) return [];
|
||||
const byKey = new Map(sessions.map((session) => [session.key, session]));
|
||||
return activeTabState.paneKeys
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((session): session is ChatSummary => session !== undefined);
|
||||
}, [activeTabState, sessions]);
|
||||
const paneChromeEnabled = Boolean(
|
||||
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
||||
);
|
||||
const renderedWorkbenchPanes = useMemo(() => {
|
||||
if (paneChromeEnabled && activeKey) {
|
||||
return workbenchPaneSessions.map((session) => ({
|
||||
key: session.key,
|
||||
reactKey: session.key === activeKey ? "tab-root" : `pane:${session.key}`,
|
||||
title: titleForSession(session),
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
key: activeKey ?? "new-topic",
|
||||
reactKey: "tab-root",
|
||||
title: headerTitle,
|
||||
}];
|
||||
}, [activeKey, headerTitle, paneChromeEnabled, titleForSession, workbenchPaneSessions]);
|
||||
const renderedActivePaneKey = paneChromeEnabled && activeTabState
|
||||
? activeTabState.activePaneKey
|
||||
: renderedWorkbenchPanes[0].key;
|
||||
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
|
||||
? activeTabState.layout
|
||||
: "columns";
|
||||
const sidebarPaneGroups = useMemo(() => {
|
||||
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
||||
return Object.fromEntries(topicSessions.map((topic) => {
|
||||
const tab = workbenchTab(workbenchState, topic.key);
|
||||
const panes = tab.paneKeys
|
||||
.map((key) => sessionsByKey.get(key))
|
||||
.filter((session): session is ChatSummary => session !== undefined)
|
||||
.map((session) => ({
|
||||
key: session.key,
|
||||
chatId: session.chatId,
|
||||
title: titleForSession(session),
|
||||
}));
|
||||
return [topic.key, {
|
||||
topicKey: topic.key,
|
||||
activePaneKey: tab.activePaneKey,
|
||||
panes,
|
||||
}];
|
||||
}));
|
||||
}, [sessions, titleForSession, topicSessions, workbenchState]);
|
||||
const attachableTabKeys = useMemo(() => (
|
||||
topicSessions
|
||||
.filter((session) => workbenchTab(workbenchState, session.key).paneKeys.length === 1)
|
||||
.map((session) => session.key)
|
||||
), [topicSessions, workbenchState]);
|
||||
const paneAcceptingTabKeys = useMemo(() => (
|
||||
topicSessions
|
||||
.filter((session) => (
|
||||
workbenchTab(workbenchState, session.key).paneKeys.length < MAX_WORKBENCH_PANES
|
||||
))
|
||||
.map((session) => session.key)
|
||||
), [topicSessions, workbenchState]);
|
||||
const activePaneLimitReached = Boolean(
|
||||
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
|
||||
);
|
||||
|
||||
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
|
||||
if (!activeKey) return;
|
||||
setWorkbenchState((current) => focusWorkbenchPane(current, activeKey, paneKey));
|
||||
}, [activeKey]);
|
||||
|
||||
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => {
|
||||
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
|
||||
if (activeKey !== tabKey) {
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: tabKey,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
}
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
||||
setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
|
||||
}, []);
|
||||
|
||||
const onPromoteWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
||||
setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey));
|
||||
}, []);
|
||||
|
||||
const onAttachWorkbenchPane = useCallback((paneKey: string, tabKey: string) => {
|
||||
if (paneKey === tabKey) return;
|
||||
setWorkbenchState((current) => attachWorkbenchPane(current, tabKey, paneKey));
|
||||
if (activeKey === paneKey) {
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: tabKey,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
}
|
||||
}, [activeKey, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "settings") {
|
||||
@@ -2148,7 +2379,7 @@ function Shell({
|
||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||
|
||||
const sidebarProps = {
|
||||
sessions,
|
||||
sessions: topicSessions,
|
||||
temporarySessions: temporarySessionList,
|
||||
activeKey: view === "chat" ? activeKey : null,
|
||||
loading,
|
||||
@@ -2157,9 +2388,17 @@ function Shell({
|
||||
onSelect: onSelectChat,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onRequestDeleteMany,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
paneGroups: sidebarPaneGroups,
|
||||
onSelectPane: onSelectSidebarPane,
|
||||
onDetachPane: onDetachWorkbenchPane,
|
||||
onPromotePane: onPromoteWorkbenchPane,
|
||||
attachableTabKeys,
|
||||
paneAcceptingTabKeys,
|
||||
onAttachPane: onAttachWorkbenchPane,
|
||||
onReorderSessions,
|
||||
onToggleGroup,
|
||||
onRequestRenameProject,
|
||||
@@ -2182,7 +2421,9 @@ function Shell({
|
||||
updatedChatIds: updatedChatIdList,
|
||||
viewState: sidebarState.view,
|
||||
showArchived: sidebarState.view.show_archived,
|
||||
archivedCount: sidebarState.archived_keys.length,
|
||||
archivedCount: topicSessions.filter(
|
||||
(session) => sidebarState.archived_keys.includes(session.key),
|
||||
).length,
|
||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||
};
|
||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||
@@ -2318,7 +2559,7 @@ function Shell({
|
||||
<SessionSearchDialog
|
||||
open
|
||||
onOpenChange={setSessionSearchOpen}
|
||||
sessions={sessions}
|
||||
sessions={topicSessions}
|
||||
activeKey={activeKey}
|
||||
loading={loading}
|
||||
titleOverrides={sidebarState.title_overrides}
|
||||
@@ -2337,6 +2578,23 @@ function Shell({
|
||||
view !== "chat" && "hidden",
|
||||
)}
|
||||
>
|
||||
<PaneWorkbench
|
||||
panes={renderedWorkbenchPanes}
|
||||
activePaneKey={renderedActivePaneKey}
|
||||
layout={renderedWorkbenchLayout}
|
||||
chrome={paneChromeEnabled}
|
||||
addPaneDisabled={creatingPane || activePaneLimitReached}
|
||||
onActivatePane={onActivateWorkbenchPane}
|
||||
onAddPane={onAddPane}
|
||||
onLayoutChange={(layout) => {
|
||||
if (!activeKey) return;
|
||||
setWorkbenchState((current) => (
|
||||
setWorkbenchLayout(current, activeKey, layout)
|
||||
));
|
||||
}}
|
||||
renderPane={(pane, context) => {
|
||||
if (!paneChromeEnabled) {
|
||||
return (
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
sessions={sessions}
|
||||
@@ -2349,7 +2607,9 @@ function Shell({
|
||||
}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
||||
onCreateChat={
|
||||
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
||||
}
|
||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
@@ -2367,6 +2627,66 @@ function Shell({
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const paneSession = workbenchPaneSessions.find(
|
||||
(session) => session.key === pane.key,
|
||||
);
|
||||
if (!paneSession) return null;
|
||||
const paneScope = workspaceOverrides[paneSession.chatId]
|
||||
?? paneSession.workspaceScope
|
||||
?? workspaces?.default_scope
|
||||
?? null;
|
||||
const paneRunning = runningChatIds.has(paneSession.chatId);
|
||||
return (
|
||||
<ThreadShell
|
||||
session={paneSession}
|
||||
sessions={sessions}
|
||||
title={pane.title}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={onForkChat}
|
||||
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggle={!context.active}
|
||||
hideSidebarToggleForHostChrome={context.active}
|
||||
hostChromeTitleInset={hostSidebarCollapsed}
|
||||
hideThemeButton={!context.active}
|
||||
hideHeaderTitle
|
||||
headerActions={context.headerActions}
|
||||
headerPortalTarget={context.headerPortalTarget}
|
||||
headerActive={context.active}
|
||||
composerPortalTarget={context.composerPortalTarget}
|
||||
composerActive={context.active}
|
||||
composerInputAriaLabel={t("workbench.composerAria", {
|
||||
defaultValue: "Message {{title}}",
|
||||
title: pane.title,
|
||||
})}
|
||||
workspaceScope={paneScope}
|
||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||
workspaceControls={workspaces?.controls ?? null}
|
||||
workspaceScopeDisabled={paneRunning}
|
||||
workspaceError={context.active ? workspaceError : null}
|
||||
onWorkspaceScopeChange={(scope) => {
|
||||
if (paneRunning) return;
|
||||
const next = normalizeWorkspaceScope(scope);
|
||||
setWorkspaceError(null);
|
||||
setWorkspaceOverrides((current) => ({
|
||||
...current,
|
||||
[paneSession.chatId]: next,
|
||||
}));
|
||||
client.setWorkspaceScope(paneSession.chatId, next);
|
||||
}}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
@@ -2398,7 +2718,8 @@ function Shell({
|
||||
<Suspense fallback={null}>
|
||||
<DeleteConfirm
|
||||
open
|
||||
title={pendingDelete.label}
|
||||
title={pendingDelete.items[0]?.label ?? ""}
|
||||
count={pendingDelete.items.length}
|
||||
automations={pendingDelete.automations}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
BringToFront,
|
||||
CornerDownRight,
|
||||
Folder,
|
||||
ListChecks,
|
||||
MessageCircleDashed,
|
||||
MoreHorizontal,
|
||||
PanelsTopLeft,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Square,
|
||||
SquareCheckBig,
|
||||
SquareMinus,
|
||||
Trash2,
|
||||
Unplug,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,6 +36,9 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -43,7 +57,13 @@ import {
|
||||
visibleSessionsForGroup,
|
||||
type ChatGroupLabels,
|
||||
} from "@/lib/chat-groups";
|
||||
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||
import {
|
||||
clearDraggedSession,
|
||||
writeDraggedPane,
|
||||
writeDraggedSession,
|
||||
type DraggedPane,
|
||||
} from "@/lib/session-drag";
|
||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
@@ -52,6 +72,21 @@ const INITIAL_VISIBLE_SESSIONS = 160;
|
||||
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
||||
|
||||
export interface SidebarPaneGroup {
|
||||
topicKey: string;
|
||||
activePaneKey: string;
|
||||
panes: Array<{
|
||||
key: string;
|
||||
chatId: string;
|
||||
title: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SidebarDeleteItem {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ChatListProps {
|
||||
sessions: ChatSummary[];
|
||||
temporarySessions?: ChatSummary[];
|
||||
@@ -59,9 +94,17 @@ interface ChatListProps {
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
||||
attachableTabKeys?: string[];
|
||||
paneAcceptingTabKeys?: string[];
|
||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
||||
onReorderSessions?: (keys: string[]) => void;
|
||||
onToggleGroup?: (groupId: string) => void;
|
||||
onRequestRenameProject?: (projectKey: string, label: string) => void;
|
||||
@@ -92,9 +135,17 @@ export const ChatList = memo(function ChatList({
|
||||
onSelect,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onRequestDeleteMany,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
paneGroups = {},
|
||||
onSelectPane,
|
||||
onDetachPane,
|
||||
onPromotePane,
|
||||
attachableTabKeys = [],
|
||||
paneAcceptingTabKeys = [],
|
||||
onAttachPane,
|
||||
onReorderSessions,
|
||||
onToggleGroup,
|
||||
onRequestRenameProject,
|
||||
@@ -124,7 +175,49 @@ export const ChatList = memo(function ChatList({
|
||||
edge: "before" | "after";
|
||||
key: string;
|
||||
} | null>(null);
|
||||
const [draggedSessionHeight, setDraggedSessionHeight] = useState(0);
|
||||
const [draggedPane, setDraggedPane] = useState<DraggedPane | null>(null);
|
||||
const [tabAttachTargetKey, setTabAttachTargetKey] = useState<string | null>(null);
|
||||
const tabAttachTargetRef = useRef<string | null>(null);
|
||||
const tabRowRefs = useRef(new Map<string, HTMLLIElement>());
|
||||
const pendingTabRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||
const tabLayoutAnimationsRef = useRef(new Map<string, Animation>());
|
||||
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
|
||||
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const selectedPaneGroup = activeKey ? paneGroups[activeKey] : undefined;
|
||||
const selectedRowKey = selectedPaneGroup
|
||||
? selectedPaneGroup.activePaneKey
|
||||
: activeKey;
|
||||
const attachableTabs = useMemo(() => new Set(attachableTabKeys), [attachableTabKeys]);
|
||||
const paneAcceptingTabs = useMemo(
|
||||
() => new Set(paneAcceptingTabKeys),
|
||||
[paneAcceptingTabKeys],
|
||||
);
|
||||
const deleteItemsByKey = useMemo(() => {
|
||||
const items = new Map<string, SidebarDeleteItem>();
|
||||
for (const group of Object.values(paneGroups)) {
|
||||
for (const pane of group.panes) {
|
||||
items.set(pane.key, { key: pane.key, label: pane.title });
|
||||
}
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (items.has(session.key)) continue;
|
||||
items.set(session.key, {
|
||||
key: session.key,
|
||||
label: displayTitle(session, titleOverrides, t("chat.newChat")),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [paneGroups, sessions, t, titleOverrides]);
|
||||
const paneMoveTargets = useMemo(() => sessions
|
||||
.filter((session) => paneAcceptingTabs.has(session.key))
|
||||
.map((session) => ({
|
||||
key: session.key,
|
||||
title: deleteItemsByKey.get(session.key)?.label ?? session.title ?? session.chatId,
|
||||
})), [deleteItemsByKey, paneAcceptingTabs, sessions]);
|
||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||
pinned: t("chat.groups.pinned"),
|
||||
all: t("chat.groups.all"),
|
||||
@@ -196,6 +289,80 @@ export const ChatList = memo(function ChatList({
|
||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||
}, [showArchived, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteSelectionMode) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setDeleteSelectionMode(false);
|
||||
setSelectedDeleteKeys(new Set());
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [deleteSelectionMode]);
|
||||
|
||||
const measureTabRows = useCallback(() => {
|
||||
const rects = new Map<string, DOMRect>();
|
||||
for (const [key, row] of tabRowRefs.current) {
|
||||
rects.set(key, row.getBoundingClientRect());
|
||||
}
|
||||
return rects;
|
||||
}, []);
|
||||
|
||||
const updateTabAttachTarget = useCallback((next: string | null) => {
|
||||
if (tabAttachTargetRef.current === next) return;
|
||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
||||
tabLayoutAnimationsRef.current.clear();
|
||||
pendingTabRectsRef.current = measureTabRows();
|
||||
tabAttachTargetRef.current = next;
|
||||
setTabAttachTargetKey(next);
|
||||
}, [measureTabRows]);
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
clearDraggedSession();
|
||||
setDraggedSessionKey(null);
|
||||
setDraggedPane(null);
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(null);
|
||||
setDraggedSessionHeight(0);
|
||||
}, [updateTabAttachTarget]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousRects = pendingTabRectsRef.current;
|
||||
if (!previousRects) return;
|
||||
pendingTabRectsRef.current = null;
|
||||
const nextRects = measureTabRows();
|
||||
const reduceMotion = typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduceMotion) return;
|
||||
for (const [key, nextRect] of nextRects) {
|
||||
const previousRect = previousRects.get(key);
|
||||
const row = tabRowRefs.current.get(key);
|
||||
if (!previousRect || !row || typeof row.animate !== "function") continue;
|
||||
const deltaY = previousRect.top - nextRect.top;
|
||||
if (Math.abs(deltaY) < 0.5) continue;
|
||||
const animation = row.animate(
|
||||
[
|
||||
{ transform: `translateY(${deltaY}px)` },
|
||||
{ transform: "translateY(0)" },
|
||||
],
|
||||
{
|
||||
duration: 180,
|
||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||
},
|
||||
);
|
||||
tabLayoutAnimationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (tabLayoutAnimationsRef.current.get(key) === animation) {
|
||||
tabLayoutAnimationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
}, [measureTabRows, tabAttachTargetKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
||||
}, []);
|
||||
|
||||
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||
@@ -218,10 +385,48 @@ export const ChatList = memo(function ChatList({
|
||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||
|
||||
const canReorderSession = (targetKey: string) => (
|
||||
!!draggedSessionKey
|
||||
!deleteSelectionMode
|
||||
&& !!draggedSessionKey
|
||||
&& draggedSessionKey !== targetKey
|
||||
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
|
||||
);
|
||||
const beginDeleteSelection = (keys: string[]) => {
|
||||
setDeleteSelectionMode(true);
|
||||
setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key))));
|
||||
};
|
||||
const toggleDeleteSelection = (keys: string[]) => {
|
||||
setSelectedDeleteKeys((current) => {
|
||||
const next = new Set(current);
|
||||
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
|
||||
const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
|
||||
for (const key of validKeys) {
|
||||
if (remove) next.delete(key);
|
||||
else next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const closeDeleteSelection = () => {
|
||||
setDeleteSelectionMode(false);
|
||||
setSelectedDeleteKeys(new Set());
|
||||
};
|
||||
const requestDeleteItems = (items: SidebarDeleteItem[]) => {
|
||||
if (items.length === 0) return;
|
||||
if (onRequestDeleteMany) onRequestDeleteMany(items);
|
||||
else if (items.length === 1) onRequestDelete(items[0].key, items[0].label);
|
||||
};
|
||||
const requestDeleteKeys = (keys: string[]) => {
|
||||
requestDeleteItems(keys
|
||||
.map((key) => deleteItemsByKey.get(key))
|
||||
.filter((item): item is SidebarDeleteItem => item !== undefined));
|
||||
};
|
||||
const confirmDeleteSelection = () => {
|
||||
requestDeleteKeys(Array.from(selectedDeleteKeys));
|
||||
closeDeleteSelection();
|
||||
};
|
||||
const draggedItemTitle = draggedPane
|
||||
? deleteItemsByKey.get(draggedPane.paneKey)?.label
|
||||
: draggedSessionKey ? deleteItemsByKey.get(draggedSessionKey)?.label : undefined;
|
||||
const reorderSession = (targetKey: string, edge: "before" | "after") => {
|
||||
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
|
||||
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
|
||||
@@ -240,7 +445,7 @@ export const ChatList = memo(function ChatList({
|
||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||
<SidebarSelectionHighlight
|
||||
targetRef={activeRowRef}
|
||||
activeId={activeKey}
|
||||
activeId={draggedSessionKey || draggedPane ? null : selectedRowKey}
|
||||
scope="sessions"
|
||||
data-chat-list-content
|
||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||
@@ -265,6 +470,12 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
|
||||
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
|
||||
const reorderOffsets = sessionReorderOffsets(
|
||||
visibleSessions.map((session) => session.key),
|
||||
draggedSessionKey,
|
||||
sessionDropTarget,
|
||||
draggedSessionHeight,
|
||||
);
|
||||
|
||||
return (
|
||||
<section key={group.id} aria-label={group.label} className="relative z-[1]">
|
||||
@@ -298,12 +509,28 @@ export const ChatList = memo(function ChatList({
|
||||
{group.kind === "project" && collapsedGroups[group.id] ? null : (
|
||||
<ul className="space-y-0.5">
|
||||
{visibleSessions.map((s) => {
|
||||
const active = s.key === activeKey;
|
||||
const topicActive = s.key === activeKey;
|
||||
const paneGroup = paneGroups[s.key];
|
||||
const fallbackTitle = t("chat.fallbackTitle", {
|
||||
id: s.chatId.slice(0, 6),
|
||||
});
|
||||
const generatedTitle = s.title?.trim() || "";
|
||||
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
|
||||
const resolvedPaneGroup = paneGroup ?? {
|
||||
topicKey: s.key,
|
||||
activePaneKey: s.key,
|
||||
panes: [{ key: s.key, chatId: s.chatId, title }],
|
||||
};
|
||||
const active = topicActive && resolvedPaneGroup.activePaneKey === s.key;
|
||||
const paneCount = resolvedPaneGroup.panes.length;
|
||||
const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key);
|
||||
const tabSelected = tabDeleteKeys.every((key) => (
|
||||
selectedDeleteKeys.has(key)
|
||||
));
|
||||
const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => (
|
||||
selectedDeleteKeys.has(key)
|
||||
));
|
||||
const isAttachTarget = tabAttachTargetKey === s.key;
|
||||
const tooltipTitle =
|
||||
titleOverrides[s.key]?.trim() ||
|
||||
generatedTitle ||
|
||||
@@ -318,24 +545,83 @@ export const ChatList = memo(function ChatList({
|
||||
const projectMode = group.kind === "project";
|
||||
const activityState = running.has(s.chatId)
|
||||
? "running"
|
||||
: updated.has(s.chatId) && !active
|
||||
: updated.has(s.chatId) && !topicActive
|
||||
? "updated"
|
||||
: null;
|
||||
return (
|
||||
<li
|
||||
key={s.key}
|
||||
className="relative min-w-0"
|
||||
ref={(element) => {
|
||||
if (element) tabRowRefs.current.set(s.key, element);
|
||||
else tabRowRefs.current.delete(s.key);
|
||||
}}
|
||||
data-session-dragging={draggedSessionKey === s.key ? "true" : undefined}
|
||||
data-session-displaced={reorderOffsets.has(s.key) ? "true" : undefined}
|
||||
data-tab-attach-target={tabAttachTargetKey === s.key ? "true" : undefined}
|
||||
className={cn(
|
||||
"relative min-w-0 rounded-xl transition-[transform,opacity,background-color,box-shadow] duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
|
||||
draggedSessionKey === s.key && "opacity-0",
|
||||
isAttachTarget
|
||||
&& "bg-sidebar-accent/35 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]",
|
||||
)}
|
||||
style={{
|
||||
transform: reorderOffsets.has(s.key)
|
||||
? `translateY(${reorderOffsets.get(s.key)}px)`
|
||||
: undefined,
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const relativeY = rect.height > 0
|
||||
? (event.clientY - rect.top) / rect.height
|
||||
: 0.5;
|
||||
const paneCanAttach = Boolean(
|
||||
!deleteSelectionMode
|
||||
&& draggedPane
|
||||
&& draggedPane.sourceTabKey !== s.key
|
||||
&& paneAcceptingTabs.has(s.key)
|
||||
&& onAttachPane,
|
||||
);
|
||||
const tabCanAttach = Boolean(
|
||||
!deleteSelectionMode
|
||||
&& draggedSessionKey
|
||||
&& draggedSessionKey !== s.key
|
||||
&& attachableTabs.has(draggedSessionKey)
|
||||
&& paneAcceptingTabs.has(s.key)
|
||||
&& relativeY >= 0.25
|
||||
&& relativeY <= 0.75
|
||||
&& onAttachPane,
|
||||
);
|
||||
if (paneCanAttach || tabCanAttach) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(s.key);
|
||||
return;
|
||||
}
|
||||
updateTabAttachTarget(null);
|
||||
if (!canReorderSession(s.key)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setSessionDropTarget({
|
||||
const nextTarget = {
|
||||
key: s.key,
|
||||
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
||||
});
|
||||
} as const;
|
||||
setSessionDropTarget((current) => (
|
||||
current?.key === nextTarget.key && current.edge === nextTarget.edge
|
||||
? current
|
||||
: nextTarget
|
||||
));
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (tabAttachTargetKey === s.key && onAttachPane) {
|
||||
const paneKey = draggedPane?.paneKey ?? draggedSessionKey;
|
||||
if (paneKey) {
|
||||
event.preventDefault();
|
||||
onAttachPane(paneKey, s.key);
|
||||
}
|
||||
resetDragState();
|
||||
return;
|
||||
}
|
||||
if (!canReorderSession(s.key)) return;
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
@@ -343,23 +629,13 @@ export const ChatList = memo(function ChatList({
|
||||
? "before"
|
||||
: "after";
|
||||
reorderSession(s.key, edge);
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
resetDragState();
|
||||
}}
|
||||
>
|
||||
{sessionDropTarget?.key === s.key ? (
|
||||
<span
|
||||
aria-hidden
|
||||
data-session-drop-edge={sessionDropTarget.edge}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
|
||||
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={active ? activeRowRef : undefined}
|
||||
data-chat-row={s.key}
|
||||
data-sidebar-tab={s.key}
|
||||
className={cn(
|
||||
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
@@ -367,36 +643,75 @@ export const ChatList = memo(function ChatList({
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
isAttachTarget
|
||||
&& "bg-sidebar-accent/65 text-sidebar-accent-foreground",
|
||||
deleteSelectionMode && (tabSelected || tabPartiallySelected)
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
draggable
|
||||
onClick={() => {
|
||||
if (deleteSelectionMode) {
|
||||
toggleDeleteSelection(tabDeleteKeys);
|
||||
return;
|
||||
}
|
||||
if (topicActive && paneGroup && onSelectPane) {
|
||||
onSelectPane(s.key, s.key);
|
||||
return;
|
||||
}
|
||||
onSelect(s.key);
|
||||
}}
|
||||
draggable={!deleteSelectionMode}
|
||||
onDragStart={(event) => {
|
||||
setDraggedSessionKey(s.key);
|
||||
setDraggedPane(null);
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(null);
|
||||
setDraggedSessionHeight(
|
||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
||||
?? event.currentTarget.getBoundingClientRect().height,
|
||||
);
|
||||
writeDraggedSession(event.dataTransfer, s.key);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
clearDraggedSession();
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
}}
|
||||
onDragEnd={resetDragState}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-pressed={deleteSelectionMode ? tabSelected : undefined}
|
||||
title={tooltipTitle}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 overflow-hidden text-left",
|
||||
"cursor-grab active:cursor-grabbing",
|
||||
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
|
||||
deleteSelectionMode
|
||||
? "cursor-default"
|
||||
: "cursor-grab active:cursor-grabbing",
|
||||
compact ? "py-1" : "py-1.5",
|
||||
projectMode && "pl-7",
|
||||
)}
|
||||
>
|
||||
{deleteSelectionMode ? (
|
||||
<SelectionIndicator
|
||||
checked={tabSelected}
|
||||
partial={tabPartiallySelected}
|
||||
/>
|
||||
) : paneCount > 1 || isAttachTarget ? (
|
||||
<PanelsTopLeft
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
{projectMode ? (
|
||||
<span className="flex w-full min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
{paneCount > 1 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
||||
>
|
||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||
{timestamp ? (
|
||||
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
||||
@@ -409,6 +724,14 @@ export const ChatList = memo(function ChatList({
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
{paneCount > 1 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
||||
>
|
||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||
</span>
|
||||
)}
|
||||
@@ -422,15 +745,16 @@ export const ChatList = memo(function ChatList({
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
<DropdownMenu modal={false}>
|
||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
||||
"focus-visible:opacity-100",
|
||||
active && "opacity-100",
|
||||
topicActive && "opacity-100",
|
||||
)}
|
||||
aria-label={t("chat.actions", { title })}
|
||||
>
|
||||
@@ -442,6 +766,17 @@ export const ChatList = memo(function ChatList({
|
||||
portalContainer={actionMenuPortalContainer}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{paneGroup
|
||||
&& paneGroup.panes.findIndex((pane) => pane.key === s.key) > 0
|
||||
&& onPromotePane ? (
|
||||
<DropdownMenuItem onSelect={() => onPromotePane(s.key, s.key)}>
|
||||
<BringToFront className="h-4 w-4 shrink-0" />
|
||||
{t("workbench.promotePane", {
|
||||
defaultValue: "Make {{title}} the primary pane",
|
||||
title,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onTogglePin(s.key)}
|
||||
>
|
||||
@@ -468,18 +803,71 @@ export const ChatList = memo(function ChatList({
|
||||
)}
|
||||
{isArchived ? t("chat.unarchive") : t("chat.archive")}
|
||||
</DropdownMenuItem>
|
||||
{attachableTabs.has(s.key) && onAttachPane ? (
|
||||
<MoveToTabSubmenu
|
||||
targets={paneMoveTargets.filter((target) => target.key !== s.key)}
|
||||
onMove={(targetKey) => onAttachPane(s.key, targetKey)}
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => beginDeleteSelection(tabDeleteKeys)}
|
||||
>
|
||||
<ListChecks className="h-4 w-4 shrink-0" />
|
||||
{t("chat.select", { defaultValue: "Select" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
onSelect={() => {
|
||||
window.setTimeout(() => onRequestDelete(s.key, title), 0);
|
||||
window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
{t("chat.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu> : null}
|
||||
</div>
|
||||
{paneCount > 1 || isAttachTarget ? (
|
||||
<ActivePaneRows
|
||||
group={resolvedPaneGroup}
|
||||
tabTitle={title}
|
||||
tabActive={topicActive}
|
||||
activeRowRef={activeRowRef}
|
||||
running={running}
|
||||
updated={updated}
|
||||
onSelectPane={onSelectPane}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onRequestRename={onRequestRename}
|
||||
onDetachPane={onDetachPane}
|
||||
onPromotePane={onPromotePane}
|
||||
moveTargets={paneMoveTargets.filter((target) => (
|
||||
target.key !== resolvedPaneGroup.topicKey
|
||||
))}
|
||||
onAttachPane={onAttachPane}
|
||||
deleteSelectionMode={deleteSelectionMode}
|
||||
selectedDeleteKeys={selectedDeleteKeys}
|
||||
onToggleDeleteSelection={toggleDeleteSelection}
|
||||
onBeginDeleteSelection={beginDeleteSelection}
|
||||
dropPreview={isAttachTarget && draggedItemTitle ? {
|
||||
paneTitle: draggedItemTitle,
|
||||
targetTitle: title,
|
||||
} : null}
|
||||
draggedPaneKey={draggedPane?.paneKey ?? null}
|
||||
onPaneDragStart={(event, pane) => {
|
||||
setDraggedPane(pane);
|
||||
setDraggedSessionKey(null);
|
||||
setSessionDropTarget(null);
|
||||
updateTabAttachTarget(null);
|
||||
setDraggedSessionHeight(
|
||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
||||
?? event.currentTarget.getBoundingClientRect().height,
|
||||
);
|
||||
writeDraggedPane(event.dataTransfer, pane);
|
||||
}}
|
||||
onPaneDragEnd={resetDragState}
|
||||
actionMenuPortalContainer={actionMenuPortalContainer}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -510,11 +898,329 @@ export const ChatList = memo(function ChatList({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{deleteSelectionMode ? (
|
||||
<div
|
||||
data-testid="delete-selection-bar"
|
||||
className="sticky bottom-2 z-30 mx-1 mt-3 flex min-h-11 items-center gap-2 rounded-2xl border border-sidebar-border/80 bg-popover/95 p-1.5 pl-2 shadow-[0_10px_30px_rgba(15,23,42,0.14)] backdrop-blur-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDeleteSelection}
|
||||
aria-label={t("chat.cancelSelection", {
|
||||
defaultValue: "Cancel selection",
|
||||
})}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate px-1 text-[12.5px] font-medium text-foreground/85">
|
||||
{t("chat.selectedCount", {
|
||||
defaultValue: "{{count}} selected",
|
||||
count: selectedDeleteKeys.size,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedDeleteKeys.size === 0}
|
||||
onClick={confirmDeleteSelection}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-[12px] font-semibold text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("chat.deleteSelected", { defaultValue: "Delete" })}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarSelectionHighlight>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function sessionReorderOffsets(
|
||||
keys: string[],
|
||||
draggedKey: string | null,
|
||||
target: { edge: "before" | "after"; key: string } | null,
|
||||
draggedHeight: number,
|
||||
): Map<string, number> {
|
||||
const offsets = new Map<string, number>();
|
||||
if (!draggedKey || !target || draggedHeight <= 0) return offsets;
|
||||
const sourceIndex = keys.indexOf(draggedKey);
|
||||
if (sourceIndex < 0 || target.key === draggedKey) return offsets;
|
||||
const remaining = keys.filter((key) => key !== draggedKey);
|
||||
const targetIndex = remaining.indexOf(target.key);
|
||||
if (targetIndex < 0) return offsets;
|
||||
const finalIndex = targetIndex + (target.edge === "after" ? 1 : 0);
|
||||
|
||||
if (sourceIndex < finalIndex) {
|
||||
for (let index = sourceIndex + 1; index <= finalIndex; index += 1) {
|
||||
offsets.set(keys[index], -draggedHeight);
|
||||
}
|
||||
} else if (sourceIndex > finalIndex) {
|
||||
for (let index = finalIndex; index < sourceIndex; index += 1) {
|
||||
offsets.set(keys[index], draggedHeight);
|
||||
}
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function ActivePaneRows({
|
||||
group,
|
||||
tabTitle,
|
||||
tabActive,
|
||||
activeRowRef,
|
||||
running,
|
||||
updated,
|
||||
onSelectPane,
|
||||
onRequestDelete,
|
||||
onRequestRename,
|
||||
onDetachPane,
|
||||
onPromotePane,
|
||||
moveTargets,
|
||||
onAttachPane,
|
||||
deleteSelectionMode,
|
||||
selectedDeleteKeys,
|
||||
onToggleDeleteSelection,
|
||||
onBeginDeleteSelection,
|
||||
dropPreview,
|
||||
draggedPaneKey,
|
||||
onPaneDragStart,
|
||||
onPaneDragEnd,
|
||||
actionMenuPortalContainer,
|
||||
}: {
|
||||
group: SidebarPaneGroup;
|
||||
tabTitle: string;
|
||||
tabActive: boolean;
|
||||
activeRowRef: RefObject<HTMLDivElement>;
|
||||
running: ReadonlySet<string>;
|
||||
updated: ReadonlySet<string>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
||||
moveTargets: Array<{ key: string; title: string }>;
|
||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
||||
deleteSelectionMode: boolean;
|
||||
selectedDeleteKeys: ReadonlySet<string>;
|
||||
onToggleDeleteSelection: (keys: string[]) => void;
|
||||
onBeginDeleteSelection: (keys: string[]) => void;
|
||||
dropPreview: { paneTitle: string; targetTitle: string } | null;
|
||||
draggedPaneKey: string | null;
|
||||
onPaneDragStart: (event: DragEvent<HTMLButtonElement>, pane: DraggedPane) => void;
|
||||
onPaneDragEnd: () => void;
|
||||
actionMenuPortalContainer?: HTMLElement | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const childPanes = group.panes.filter((pane) => pane.key !== group.topicKey);
|
||||
|
||||
return (
|
||||
<ul
|
||||
aria-label={t("workbench.panesInTab", {
|
||||
defaultValue: "Panes in {{title}}",
|
||||
title: tabTitle,
|
||||
})}
|
||||
className={cn(
|
||||
"relative ml-5 mr-1 mt-0.5 space-y-0.5 rounded-bl-lg border-l border-sidebar-border/60 py-0.5 pl-2 pr-0.5",
|
||||
dropPreview && "pb-1",
|
||||
)}
|
||||
>
|
||||
{childPanes.map((pane) => {
|
||||
const index = group.panes.findIndex((candidate) => candidate.key === pane.key);
|
||||
const active = tabActive && pane.key === group.activePaneKey;
|
||||
const activityState = running.has(pane.chatId)
|
||||
? "running"
|
||||
: updated.has(pane.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
const paneActionsLabel = t("workbench.paneActions", {
|
||||
defaultValue: "{{title}} pane actions",
|
||||
title: pane.title,
|
||||
});
|
||||
const selected = selectedDeleteKeys.has(pane.key);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pane.key}
|
||||
data-pane-dragging={draggedPaneKey === pane.key ? "true" : undefined}
|
||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-sidebar-border/45"
|
||||
>
|
||||
<div
|
||||
ref={active ? activeRowRef : undefined}
|
||||
data-chat-row={pane.key}
|
||||
data-sidebar-pane={pane.key}
|
||||
className={cn(
|
||||
"group/pane flex min-h-7 min-w-0 items-center gap-1 rounded-lg px-2 text-[12.5px]",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/72 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
deleteSelectionMode && selected
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (deleteSelectionMode) {
|
||||
onToggleDeleteSelection([pane.key]);
|
||||
return;
|
||||
}
|
||||
onSelectPane?.(group.topicKey, pane.key);
|
||||
}}
|
||||
draggable={!deleteSelectionMode}
|
||||
onDragStart={(event) => onPaneDragStart(event, {
|
||||
paneKey: pane.key,
|
||||
sourceTabKey: group.topicKey,
|
||||
})}
|
||||
onDragEnd={onPaneDragEnd}
|
||||
aria-current={active ? "true" : undefined}
|
||||
aria-pressed={deleteSelectionMode ? selected : undefined}
|
||||
title={pane.title}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 py-1 text-left font-medium leading-5",
|
||||
deleteSelectionMode ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
>
|
||||
{deleteSelectionMode ? (
|
||||
<SelectionIndicator checked={selected} partial={false} />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate">{pane.title}</span>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover/pane:opacity-100",
|
||||
"focus-visible:opacity-100",
|
||||
active && "opacity-100",
|
||||
)}
|
||||
aria-label={paneActionsLabel}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={ACTION_MENU_CONTENT_CLASS}
|
||||
portalContainer={actionMenuPortalContainer}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{index > 0 && onPromotePane ? (
|
||||
<DropdownMenuItem onSelect={() => onPromotePane(group.topicKey, pane.key)}>
|
||||
<BringToFront className="h-4 w-4 shrink-0" />
|
||||
{t("workbench.promotePane", {
|
||||
defaultValue: "Make {{title}} the primary pane",
|
||||
title: pane.title,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onRequestRename(pane.key, pane.title)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
{t("chat.rename")}
|
||||
</DropdownMenuItem>
|
||||
{onDetachPane ? (
|
||||
<DropdownMenuItem onSelect={() => onDetachPane(group.topicKey, pane.key)}>
|
||||
<Unplug className="h-4 w-4 shrink-0" />
|
||||
{t("workbench.detachPane", {
|
||||
defaultValue: "Move {{title}} to its own topic",
|
||||
title: pane.title,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onAttachPane ? (
|
||||
<MoveToTabSubmenu
|
||||
targets={moveTargets}
|
||||
onMove={(targetKey) => onAttachPane(pane.key, targetKey)}
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onBeginDeleteSelection([pane.key])}
|
||||
>
|
||||
<ListChecks className="h-4 w-4 shrink-0" />
|
||||
{t("chat.select", { defaultValue: "Select" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
onSelect={() => {
|
||||
window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
{t("chat.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{dropPreview ? (
|
||||
<li
|
||||
data-pane-drop-preview
|
||||
role="status"
|
||||
aria-label={t("workbench.dropPane", {
|
||||
defaultValue: "Move {{pane}} into {{tab}}",
|
||||
pane: dropPreview.paneTitle,
|
||||
tab: dropPreview.targetTitle,
|
||||
})}
|
||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-primary/45"
|
||||
>
|
||||
<div className="flex min-h-7 items-center gap-2 rounded-lg border border-primary/30 bg-primary/[0.07] px-2 text-[12.5px] font-medium text-foreground/80 shadow-[inset_0_0_0_1px_hsl(var(--background)/0.5)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-150">
|
||||
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-primary/75" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">{dropPreview.paneTitle}</span>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionIndicator({
|
||||
checked,
|
||||
partial,
|
||||
}: {
|
||||
checked: boolean;
|
||||
partial: boolean;
|
||||
}) {
|
||||
const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square;
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
checked || partial ? "text-primary" : "text-muted-foreground/55",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveToTabSubmenu({
|
||||
targets,
|
||||
onMove,
|
||||
}: {
|
||||
targets: Array<{ key: string; title: string }>;
|
||||
onMove: (targetKey: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (targets.length === 0) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{t("workbench.moveToTab", { defaultValue: "Move to tab" })}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{targets.map((target) => (
|
||||
<DropdownMenuItem key={target.key} onSelect={() => onMove(target.key)}>
|
||||
<span className="max-w-56 truncate">{target.title}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function TemporaryChatSection({
|
||||
sessions,
|
||||
activeKey,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
|
||||
interface DeleteConfirmProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
count?: number;
|
||||
automations?: SessionAutomationJob[];
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
|
||||
export function DeleteConfirm({
|
||||
open,
|
||||
title,
|
||||
count = 1,
|
||||
automations = [],
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@@ -33,6 +35,7 @@ export function DeleteConfirm({
|
||||
const { t } = useTranslation();
|
||||
const locale = currentLocale();
|
||||
const hasAutomations = automations.length > 0;
|
||||
const multiple = count > 1;
|
||||
const visibleAutomations = automations.slice(0, 4);
|
||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||
return (
|
||||
@@ -47,11 +50,24 @@ export function DeleteConfirm({
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||
{t("deleteConfirm.title", { title })}
|
||||
{multiple
|
||||
? t("deleteConfirm.titleMany", {
|
||||
defaultValue: "Delete {{count}} topics and panes?",
|
||||
count,
|
||||
})
|
||||
: t("deleteConfirm.title", { title })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||
{hasAutomations
|
||||
? t("deleteConfirm.automationsDescription")
|
||||
? multiple
|
||||
? t("deleteConfirm.automationsDescriptionMany", {
|
||||
defaultValue: "Linked automations will also be deleted.",
|
||||
})
|
||||
: t("deleteConfirm.automationsDescription")
|
||||
: multiple
|
||||
? t("deleteConfirm.descriptionMany", {
|
||||
defaultValue: "This action cannot be undone.",
|
||||
})
|
||||
: t("deleteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
{hasAutomations ? (
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import {
|
||||
ChatList,
|
||||
type SidebarDeleteItem,
|
||||
type SidebarPaneGroup,
|
||||
} from "@/components/ChatList";
|
||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||
import {
|
||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||
@@ -39,9 +43,17 @@ interface SidebarProps {
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
||||
attachableTabKeys?: string[];
|
||||
paneAcceptingTabKeys?: string[];
|
||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
||||
onReorderSessions: (keys: string[]) => void;
|
||||
onToggleGroup: (groupId: string) => void;
|
||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||
@@ -230,9 +242,17 @@ export function Sidebar(props: SidebarProps) {
|
||||
onSelect={props.onSelect}
|
||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
onRequestDeleteMany={props.onRequestDeleteMany}
|
||||
onTogglePin={props.onTogglePin}
|
||||
onRequestRename={props.onRequestRename}
|
||||
onToggleArchive={props.onToggleArchive}
|
||||
paneGroups={props.paneGroups}
|
||||
onSelectPane={props.onSelectPane}
|
||||
onDetachPane={props.onDetachPane}
|
||||
onPromotePane={props.onPromotePane}
|
||||
attachableTabKeys={props.attachableTabKeys}
|
||||
paneAcceptingTabKeys={props.paneAcceptingTabKeys}
|
||||
onAttachPane={props.onAttachPane}
|
||||
onReorderSessions={props.onReorderSessions}
|
||||
onToggleGroup={props.onToggleGroup}
|
||||
onRequestRenameProject={props.onRequestRenameProject}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -186,6 +186,7 @@ interface ThreadComposerProps {
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
isStreaming?: boolean;
|
||||
modelLabel?: string | null;
|
||||
modelDetail?: string | null;
|
||||
@@ -940,6 +941,7 @@ export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputAriaLabel,
|
||||
isStreaming = false,
|
||||
modelLabel = null,
|
||||
modelDetail = null,
|
||||
@@ -2400,7 +2402,7 @@ export function ThreadComposer({
|
||||
rows={1}
|
||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||
disabled={interactionDisabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
inputTextClasses,
|
||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||
|
||||
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideTitle?: boolean;
|
||||
actions?: ReactNode;
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
@@ -33,8 +36,11 @@ export function ThreadHeader({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideTitle = false,
|
||||
actions,
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
@@ -54,6 +60,7 @@ export function ThreadHeader({
|
||||
)}
|
||||
>
|
||||
<div className="relative flex min-w-0 items-center gap-2">
|
||||
{!hideSidebarToggle ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -66,7 +73,8 @@ export function ThreadHeader({
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!minimal ? (
|
||||
) : null}
|
||||
{!minimal && !hideTitle ? (
|
||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||
</div>
|
||||
@@ -76,6 +84,7 @@ export function ThreadHeader({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{actions}
|
||||
{onTemporaryChatEnabledChange ? (
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
@@ -311,9 +312,17 @@ interface ThreadShellProps {
|
||||
theme?: "light" | "dark";
|
||||
onToggleTheme?: () => void;
|
||||
hideSidebarToggleForHostChrome?: boolean;
|
||||
hideSidebarToggle?: boolean;
|
||||
hostChromeTitleInset?: boolean;
|
||||
hideThemeButton?: boolean;
|
||||
hideHeaderTitle?: boolean;
|
||||
hideHeader?: boolean;
|
||||
headerActions?: ReactNode;
|
||||
headerPortalTarget?: HTMLElement | null;
|
||||
headerActive?: boolean;
|
||||
composerPortalTarget?: HTMLElement | null;
|
||||
composerActive?: boolean;
|
||||
composerInputAriaLabel?: string;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
@@ -598,9 +607,17 @@ export function ThreadShell({
|
||||
theme = "light",
|
||||
onToggleTheme = () => {},
|
||||
hideSidebarToggleForHostChrome = false,
|
||||
hideSidebarToggle = false,
|
||||
hostChromeTitleInset = false,
|
||||
hideThemeButton = false,
|
||||
hideHeaderTitle = false,
|
||||
hideHeader = false,
|
||||
headerActions,
|
||||
headerPortalTarget,
|
||||
headerActive = true,
|
||||
composerPortalTarget,
|
||||
composerActive = true,
|
||||
composerInputAriaLabel,
|
||||
workspaceScope = null,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
@@ -1405,6 +1422,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
@@ -1449,6 +1467,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleWelcomeSend}
|
||||
disabled={booting}
|
||||
inputAriaLabel={composerInputAriaLabel}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
booting
|
||||
@@ -1508,18 +1527,18 @@ export function ThreadShell({
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{!hideHeader ? (
|
||||
const threadHeader = !hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||
hideSidebarToggle={hideSidebarToggle}
|
||||
hostChromeTitleInset={hostChromeTitleInset}
|
||||
hideThemeButton={hideThemeButton}
|
||||
hideTitle={hideHeaderTitle}
|
||||
actions={headerActions}
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
@@ -1529,7 +1548,12 @@ export function ThreadShell({
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{headerPortalTarget === undefined ? threadHeader : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||
>
|
||||
@@ -1539,7 +1563,7 @@ export function ThreadShell({
|
||||
temporary={temporary}
|
||||
isStreaming={turnActive}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
composer={composerPortalTarget === undefined ? composer : null}
|
||||
activeTurnId={viewportTurnId}
|
||||
activeTurnStartedHere={activeTurnStartedHere}
|
||||
conversationKey={historyKey}
|
||||
@@ -1559,6 +1583,19 @@ export function ThreadShell({
|
||||
/>
|
||||
</FilePreviewAvailabilityProvider>
|
||||
</div>
|
||||
{headerPortalTarget && headerActive
|
||||
? createPortal(threadHeader, headerPortalTarget)
|
||||
: null}
|
||||
{composerPortalTarget ? createPortal(
|
||||
<div
|
||||
hidden={!composerActive}
|
||||
aria-hidden={!composerActive}
|
||||
data-testid={composerActive ? "active-pane-composer" : undefined}
|
||||
>
|
||||
{composer}
|
||||
</div>,
|
||||
composerPortalTarget,
|
||||
) : null}
|
||||
{filePreviewPath && historyKey ? (
|
||||
<FilePreviewPanel
|
||||
sessionKey={historyKey}
|
||||
|
||||
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
composer?: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
activeTurnId?: string | null;
|
||||
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const NEAR_TOP_PX = 96;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||
? forkBoundaryMessageCount - hiddenMessageCount
|
||||
: null;
|
||||
const hasComposer = composer !== null && composer !== undefined;
|
||||
const scrollButtonBottom =
|
||||
keyboardInsetBottom
|
||||
+ (composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||
: hasComposer
|
||||
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
|
||||
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
|
||||
const scrollViewportStyle =
|
||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||
|
||||
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||
data-layout={hasMessages ? "thread" : "hero"}
|
||||
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
||||
className={cn(
|
||||
"thread-layout mx-auto grid min-h-full w-full",
|
||||
hasMessages
|
||||
@@ -699,11 +703,17 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
||||
<div
|
||||
className={cn(
|
||||
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
||||
hasComposer && "sm:items-end sm:pb-8",
|
||||
)}
|
||||
>
|
||||
{emptyState}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasComposer ? (
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
@@ -746,11 +756,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasComposer ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Circle } from "lucide-react";
|
||||
import { ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import {
|
||||
floatingItemClassName,
|
||||
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const menuItemClassName =
|
||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||
@@ -115,6 +116,41 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(menuItemClassName, inset && "pl-8", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -123,5 +159,8 @@ export {
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
Columns2,
|
||||
Grid2X2,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Rows2,
|
||||
Square,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FocusEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WorkbenchPane {
|
||||
key: string;
|
||||
reactKey?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface PaneRenderContext {
|
||||
active: boolean;
|
||||
headerPortalTarget: HTMLElement | null | undefined;
|
||||
composerPortalTarget: HTMLElement | null | undefined;
|
||||
headerActions: ReactNode;
|
||||
}
|
||||
|
||||
interface PaneWorkbenchProps {
|
||||
panes: WorkbenchPane[];
|
||||
activePaneKey: string;
|
||||
layout: WorkbenchLayout;
|
||||
chrome?: boolean;
|
||||
addPaneDisabled?: boolean;
|
||||
onActivatePane: (key: string) => void;
|
||||
onAddPane: () => void;
|
||||
onLayoutChange: (layout: WorkbenchLayout) => void;
|
||||
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
|
||||
}
|
||||
|
||||
const LAYOUT_MOTION_DURATION_MS = 260;
|
||||
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
const LAYOUT_CONTROLS: Array<{
|
||||
icon: LucideIcon;
|
||||
layout: WorkbenchLayout;
|
||||
label: string;
|
||||
}> = [
|
||||
{ icon: Columns2, layout: "columns", label: "Columns" },
|
||||
{ icon: Rows2, layout: "rows", label: "Rows" },
|
||||
{ icon: Grid2X2, layout: "grid", label: "Grid" },
|
||||
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
|
||||
{ icon: Square, layout: "monocle", label: "Monocle" },
|
||||
];
|
||||
|
||||
function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSProperties {
|
||||
const count = Math.max(1, paneCount);
|
||||
switch (layout) {
|
||||
case "columns":
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
};
|
||||
case "rows":
|
||||
return {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: `repeat(${count}, minmax(0, 1fr))`,
|
||||
};
|
||||
case "grid": {
|
||||
const columns = Math.ceil(Math.sqrt(count));
|
||||
const rows = Math.ceil(count / columns);
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
||||
};
|
||||
}
|
||||
case "main-stack":
|
||||
return count === 1
|
||||
? {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
}
|
||||
: {
|
||||
gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)",
|
||||
gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`,
|
||||
};
|
||||
case "monocle":
|
||||
return {
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function paneCellStyle(
|
||||
layout: WorkbenchLayout,
|
||||
paneCount: number,
|
||||
index: number,
|
||||
): CSSProperties | undefined {
|
||||
if (layout !== "main-stack" || paneCount < 2) return undefined;
|
||||
return index === 0
|
||||
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` }
|
||||
: { gridColumn: 2, gridRow: index };
|
||||
}
|
||||
|
||||
function isPaneAction(target: EventTarget | null): boolean {
|
||||
return target instanceof Element
|
||||
&& target.closest("[data-workbench-pane-action]") !== null;
|
||||
}
|
||||
|
||||
function HeaderIconButton({
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneWorkbench({
|
||||
panes,
|
||||
activePaneKey,
|
||||
layout,
|
||||
chrome = true,
|
||||
addPaneDisabled = false,
|
||||
onActivatePane,
|
||||
onAddPane,
|
||||
onLayoutChange,
|
||||
renderPane,
|
||||
}: PaneWorkbenchProps) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useMediaQuery("(max-width: 767px)");
|
||||
const effectiveLayout = compact ? "monocle" : layout;
|
||||
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const paneRefs = useRef(new Map<string, HTMLElement>());
|
||||
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
||||
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
||||
const animationsRef = useRef(new Map<string, Animation>());
|
||||
const paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]);
|
||||
|
||||
const measurePanes = useCallback(() => {
|
||||
const rects = new Map<string, DOMRect>();
|
||||
for (const [key, element] of paneRefs.current) {
|
||||
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
|
||||
}
|
||||
return rects;
|
||||
}, []);
|
||||
|
||||
const captureLayout = useCallback(() => {
|
||||
pendingRectsRef.current = measurePanes();
|
||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||
animationsRef.current.clear();
|
||||
}, [measurePanes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
||||
pendingRectsRef.current = null;
|
||||
const nextRects = measurePanes();
|
||||
const reduceMotion = typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
if (!reduceMotion) {
|
||||
for (const [key, nextRect] of nextRects) {
|
||||
const previousRect = previousRects.get(key);
|
||||
const element = paneRefs.current.get(key);
|
||||
if (!element) continue;
|
||||
if (!previousRect) {
|
||||
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
||||
const animation = element.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
|
||||
{ opacity: 1, transform: "translateY(0) scale(1)" },
|
||||
],
|
||||
{
|
||||
duration: 180,
|
||||
easing: LAYOUT_MOTION_EASING,
|
||||
fill: "backwards",
|
||||
},
|
||||
);
|
||||
animationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (animationsRef.current.get(key) === animation) {
|
||||
animationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
continue;
|
||||
}
|
||||
if (previousRect.width === 0 || previousRect.height === 0) continue;
|
||||
const deltaX = previousRect.left - nextRect.left;
|
||||
const deltaY = previousRect.top - nextRect.top;
|
||||
const scaleX = previousRect.width / nextRect.width;
|
||||
const scaleY = previousRect.height / nextRect.height;
|
||||
if (
|
||||
Math.abs(deltaX) < 0.5
|
||||
&& Math.abs(deltaY) < 0.5
|
||||
&& Math.abs(scaleX - 1) < 0.002
|
||||
&& Math.abs(scaleY - 1) < 0.002
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (typeof element.animate !== "function") continue;
|
||||
const animation = element.animate(
|
||||
[
|
||||
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
|
||||
{ transform: "translate(0, 0) scale(1, 1)" },
|
||||
],
|
||||
{
|
||||
duration: LAYOUT_MOTION_DURATION_MS,
|
||||
easing: LAYOUT_MOTION_EASING,
|
||||
},
|
||||
);
|
||||
animationsRef.current.set(key, animation);
|
||||
animation.addEventListener("finish", () => {
|
||||
if (animationsRef.current.get(key) === animation) {
|
||||
animationsRef.current.delete(key);
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
lastRectsRef.current = nextRects;
|
||||
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
||||
}, []);
|
||||
|
||||
const activatePane = useCallback((key: string, target: EventTarget | null) => {
|
||||
if (key === activePaneKey || isPaneAction(target)) return;
|
||||
captureLayout();
|
||||
onActivatePane(key);
|
||||
}, [activePaneKey, captureLayout, onActivatePane]);
|
||||
|
||||
const handlePanePointerDown = useCallback((
|
||||
key: string,
|
||||
event: PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
activatePane(key, event.target);
|
||||
}, [activatePane]);
|
||||
|
||||
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
|
||||
activatePane(key, event.target);
|
||||
}, [activatePane]);
|
||||
|
||||
const gridStyle = paneGridStyle(effectiveLayout, panes.length);
|
||||
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
|
||||
?? LAYOUT_CONTROLS[0];
|
||||
const headerActions = chrome ? (
|
||||
<div
|
||||
data-workbench-pane-action
|
||||
className="host-no-drag flex items-center gap-0.5"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("workbench.layout", {
|
||||
defaultValue: "Pane layout",
|
||||
})}
|
||||
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<currentLayout.icon className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("workbench.layout", { defaultValue: "Pane layout" })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={layout}
|
||||
onValueChange={(value) => {
|
||||
const next = value as WorkbenchLayout;
|
||||
if (next === layout) return;
|
||||
captureLayout();
|
||||
onLayoutChange(next);
|
||||
}}
|
||||
>
|
||||
{LAYOUT_CONTROLS.map((control) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={control.layout}
|
||||
value={control.layout}
|
||||
>
|
||||
<control.icon aria-hidden />
|
||||
{t(`workbench.layouts.${control.layout}`, {
|
||||
defaultValue: control.label,
|
||||
})}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<HeaderIconButton
|
||||
disabled={addPaneDisabled}
|
||||
icon={Plus}
|
||||
label={t("workbench.addPane", { defaultValue: "Add pane" })}
|
||||
onClick={() => {
|
||||
captureLayout();
|
||||
onAddPane();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
|
||||
>
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
{chrome ? (
|
||||
<header className="shrink-0 bg-background">
|
||||
<div
|
||||
ref={setHeaderPortalTarget}
|
||||
data-testid="workbench-header-host"
|
||||
/>
|
||||
</header>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 bg-background">
|
||||
<div
|
||||
data-testid="pane-grid"
|
||||
data-layout={effectiveLayout}
|
||||
className={cn(
|
||||
"grid h-full min-h-0 min-w-0 overflow-hidden",
|
||||
chrome && panes.length > 1 && "gap-px bg-border/55",
|
||||
)}
|
||||
style={gridStyle}
|
||||
>
|
||||
{panes.map((pane, index) => {
|
||||
const active = pane.key === activePaneKey;
|
||||
const hidden = effectiveLayout === "monocle" && !active;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={pane.reactKey ?? pane.key}
|
||||
ref={(element) => {
|
||||
if (element) paneRefs.current.set(pane.key, element);
|
||||
else paneRefs.current.delete(pane.key);
|
||||
}}
|
||||
hidden={hidden}
|
||||
aria-label={pane.title}
|
||||
data-active={active ? "true" : "false"}
|
||||
data-testid={`workbench-pane-${pane.key}`}
|
||||
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
|
||||
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
|
||||
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
style={paneCellStyle(effectiveLayout, panes.length, index)}
|
||||
>
|
||||
{renderPane(pane, {
|
||||
active,
|
||||
headerPortalTarget: chrome ? headerPortalTarget : undefined,
|
||||
composerPortalTarget: chrome ? composerPortalTarget : undefined,
|
||||
headerActions,
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chrome ? (
|
||||
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||
<div
|
||||
ref={setComposerPortalTarget}
|
||||
data-testid="workbench-composer-host"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
/>
|
||||
</footer>
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
export const WORKBENCH_STORAGE_KEY = "nanobot.webui.workbench.v2";
|
||||
export const MAX_WORKBENCH_PANES = 4;
|
||||
|
||||
export const WORKBENCH_LAYOUTS = [
|
||||
"columns",
|
||||
"rows",
|
||||
"grid",
|
||||
"main-stack",
|
||||
"monocle",
|
||||
] as const;
|
||||
|
||||
export type WorkbenchLayout = (typeof WORKBENCH_LAYOUTS)[number];
|
||||
|
||||
export interface WorkbenchTabState {
|
||||
paneKeys: string[];
|
||||
activePaneKey: string;
|
||||
layout: WorkbenchLayout;
|
||||
}
|
||||
|
||||
export interface WorkbenchState {
|
||||
version: 2;
|
||||
tabs: Record<string, WorkbenchTabState>;
|
||||
}
|
||||
|
||||
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
|
||||
version: 2,
|
||||
tabs: {},
|
||||
};
|
||||
|
||||
function isLayout(value: unknown): value is WorkbenchLayout {
|
||||
return typeof value === "string"
|
||||
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function uniqueKeys(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(new Set(
|
||||
value.filter((key): key is string => typeof key === "string" && key.length > 0),
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeTab(value: unknown, tabKey: string): WorkbenchTabState {
|
||||
const candidate = value && typeof value === "object"
|
||||
? value as Partial<WorkbenchTabState>
|
||||
: {};
|
||||
const paneKeys = uniqueKeys(candidate.paneKeys);
|
||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
||||
? paneKeys
|
||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
||||
return {
|
||||
paneKeys: normalizedPaneKeys,
|
||||
activePaneKey:
|
||||
typeof candidate.activePaneKey === "string"
|
||||
&& normalizedPaneKeys.includes(candidate.activePaneKey)
|
||||
? candidate.activePaneKey
|
||||
: normalizedPaneKeys[0],
|
||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkbenchState(serialized: string | null): WorkbenchState {
|
||||
if (!serialized) return EMPTY_WORKBENCH_STATE;
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as { version?: unknown; tabs?: unknown };
|
||||
if (
|
||||
parsed.version !== 2
|
||||
|| !parsed.tabs
|
||||
|| typeof parsed.tabs !== "object"
|
||||
|| Array.isArray(parsed.tabs)
|
||||
) {
|
||||
return EMPTY_WORKBENCH_STATE;
|
||||
}
|
||||
const tabs = Object.fromEntries(
|
||||
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab, tabKey)]),
|
||||
);
|
||||
return { version: 2, tabs };
|
||||
} catch {
|
||||
return EMPTY_WORKBENCH_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultWorkbenchTab(tabKey: string): WorkbenchTabState {
|
||||
return {
|
||||
paneKeys: [tabKey],
|
||||
activePaneKey: tabKey,
|
||||
layout: "columns",
|
||||
};
|
||||
}
|
||||
|
||||
export function workbenchTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
): WorkbenchTabState {
|
||||
return state.tabs[tabKey] ?? defaultWorkbenchTab(tabKey);
|
||||
}
|
||||
|
||||
function updateTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
update: (tab: WorkbenchTabState) => WorkbenchTabState,
|
||||
): WorkbenchState {
|
||||
const current = workbenchTab(state, tabKey);
|
||||
const next = update(current);
|
||||
if (state.tabs[tabKey] === next) return state;
|
||||
return {
|
||||
version: 2,
|
||||
tabs: {
|
||||
...state.tabs,
|
||||
[tabKey]: next,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureWorkbenchTab(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
): WorkbenchState {
|
||||
if (state.tabs[tabKey]) return state;
|
||||
return updateTab(state, tabKey, (tab) => tab);
|
||||
}
|
||||
|
||||
export function addWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
if (tab.paneKeys.includes(paneKey)) {
|
||||
if (tab.activePaneKey === paneKey) return tab;
|
||||
return { ...tab, activePaneKey: paneKey };
|
||||
}
|
||||
if (tab.paneKeys.length >= MAX_WORKBENCH_PANES) return tab;
|
||||
return {
|
||||
...tab,
|
||||
paneKeys: [...tab.paneKeys, paneKey],
|
||||
activePaneKey: paneKey,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function focusWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.paneKeys.includes(paneKey) && tab.activePaneKey !== paneKey
|
||||
? { ...tab, activePaneKey: paneKey }
|
||||
: tab
|
||||
));
|
||||
}
|
||||
|
||||
export function detachWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
const index = tab.paneKeys.indexOf(paneKey);
|
||||
if (index < 0 || paneKey === tabKey || tab.paneKeys.length === 1) return tab;
|
||||
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
|
||||
const activePaneKey = tab.activePaneKey === paneKey
|
||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
||||
: tab.activePaneKey;
|
||||
return { ...tab, paneKeys, activePaneKey };
|
||||
});
|
||||
}
|
||||
|
||||
export function attachWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
targetTabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
if (!targetTabKey || !paneKey || targetTabKey === paneKey) return state;
|
||||
|
||||
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
|
||||
tab.paneKeys.includes(paneKey)
|
||||
));
|
||||
const sourceTabKey = sourceEntry?.[0];
|
||||
const sourceTab = sourceEntry?.[1];
|
||||
if (sourceTabKey === targetTabKey) {
|
||||
return focusWorkbenchPane(state, targetTabKey, paneKey);
|
||||
}
|
||||
if (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) {
|
||||
return state;
|
||||
}
|
||||
const targetBeforeMove = state.tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
||||
if (
|
||||
!targetBeforeMove.paneKeys.includes(paneKey)
|
||||
&& targetBeforeMove.paneKeys.length >= MAX_WORKBENCH_PANES
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tabs = { ...state.tabs };
|
||||
if (sourceTabKey && sourceTab) {
|
||||
if (sourceTabKey === paneKey) {
|
||||
delete tabs[sourceTabKey];
|
||||
} else {
|
||||
const index = sourceTab.paneKeys.indexOf(paneKey);
|
||||
const paneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
|
||||
tabs[sourceTabKey] = {
|
||||
...sourceTab,
|
||||
paneKeys,
|
||||
activePaneKey: sourceTab.activePaneKey === paneKey
|
||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
||||
: sourceTab.activePaneKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
||||
tabs[targetTabKey] = targetTab.paneKeys.includes(paneKey)
|
||||
? { ...targetTab, activePaneKey: paneKey }
|
||||
: {
|
||||
...targetTab,
|
||||
paneKeys: [...targetTab.paneKeys, paneKey],
|
||||
activePaneKey: paneKey,
|
||||
};
|
||||
return { version: 2, tabs };
|
||||
}
|
||||
|
||||
export function promoteWorkbenchPane(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
paneKey: string,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => {
|
||||
const index = tab.paneKeys.indexOf(paneKey);
|
||||
if (index <= 0) return tab;
|
||||
return {
|
||||
...tab,
|
||||
paneKeys: [paneKey, ...tab.paneKeys.filter((key) => key !== paneKey)],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function setWorkbenchLayout(
|
||||
state: WorkbenchState,
|
||||
tabKey: string,
|
||||
layout: WorkbenchLayout,
|
||||
): WorkbenchState {
|
||||
return updateTab(state, tabKey, (tab) => (
|
||||
tab.layout === layout ? tab : { ...tab, layout }
|
||||
));
|
||||
}
|
||||
|
||||
export function reconcileWorkbench(
|
||||
state: WorkbenchState,
|
||||
validKeys: ReadonlySet<string>,
|
||||
): WorkbenchState {
|
||||
const tabs: Record<string, WorkbenchTabState> = {};
|
||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
||||
if (!validKeys.has(tabKey)) continue;
|
||||
const paneKeys = tab.paneKeys.filter((key) => validKeys.has(key));
|
||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
||||
? paneKeys
|
||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
||||
tabs[tabKey] = {
|
||||
...tab,
|
||||
paneKeys: normalizedPaneKeys,
|
||||
activePaneKey: normalizedPaneKeys.includes(tab.activePaneKey)
|
||||
? tab.activePaneKey
|
||||
: normalizedPaneKeys[0],
|
||||
};
|
||||
}
|
||||
const serializedCurrent = JSON.stringify(state.tabs);
|
||||
const serializedNext = JSON.stringify(tabs);
|
||||
return serializedCurrent === serializedNext ? state : { version: 2, tabs };
|
||||
}
|
||||
|
||||
export function workbenchChildPaneKeys(state: WorkbenchState): Set<string> {
|
||||
const childKeys = new Set<string>();
|
||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
||||
for (const paneKey of tab.paneKeys) {
|
||||
if (paneKey !== tabKey) childKeys.add(paneKey);
|
||||
}
|
||||
}
|
||||
return childKeys;
|
||||
}
|
||||
@@ -360,6 +360,9 @@
|
||||
.thread-layout[data-layout="thread"] {
|
||||
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
||||
}
|
||||
.thread-layout[data-layout="external"] {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.thread-layout[data-layout="hero"] {
|
||||
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
||||
@@ -564,6 +567,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.workbench-pane {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
||||
@keyframes goal-shell-glow-breathe {
|
||||
0%,
|
||||
|
||||
@@ -319,8 +319,8 @@
|
||||
"filterInstalled": "Enabled",
|
||||
"filterNotInstalled": "Not enabled",
|
||||
"searchPlaceholder": "Search MCP presets",
|
||||
"moreOptions": "Add MCP server",
|
||||
"moreOptionsSubtitle": "Connect a custom MCP server or import an existing configuration.",
|
||||
"moreOptions": "Add integration",
|
||||
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
|
||||
"customTitle": "Custom MCP",
|
||||
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
|
||||
"customAction": "Custom",
|
||||
@@ -328,14 +328,9 @@
|
||||
"serverName": "Server name",
|
||||
"serverUrl": "URL",
|
||||
"transport": "Transport",
|
||||
"authentication": "Authentication",
|
||||
"authNone": "None",
|
||||
"authHeaders": "Headers",
|
||||
"command": "Command",
|
||||
"args": "Args JSON",
|
||||
"headers": "Headers JSON",
|
||||
"oauthAfterSave": "Save the server, then select Connect to sign in.",
|
||||
"headersHelp": "Add the request headers used by this server.",
|
||||
"env": "Env JSON",
|
||||
"timeout": "Tool timeout",
|
||||
"advancedOptions": "Advanced options",
|
||||
@@ -362,21 +357,6 @@
|
||||
"keepExisting": "Leave blank to keep existing",
|
||||
"statusConfigured": "Configured",
|
||||
"statusMissingCredentials": "Needs key",
|
||||
"connectingAccount": "Connecting {{name}}",
|
||||
"connectingLabel": "Connecting…",
|
||||
"continueSignIn": "Continue sign-in",
|
||||
"preparingSignIn": "Preparing secure sign-in…",
|
||||
"openSignInToContinue": "Open the sign-in page to continue.",
|
||||
"finishSignInInBrowser": "Finish signing in in the browser window.",
|
||||
"manualCallbackRequired": "Finish signing in, then paste the callback URL into nanobot.",
|
||||
"manualCallbackHelp": "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
|
||||
"finishingConnection": "Finishing connection…",
|
||||
"activatingTools": "Activating tools…",
|
||||
"connected": "Connected.",
|
||||
"connectionFailed": "Connection failed.",
|
||||
"connectionCancelled": "Connection cancelled.",
|
||||
"reloadFailed": "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
|
||||
"oauthFailed": "Unable to connect. Try signing in again.",
|
||||
"statusMissingDependency": "Needs dependency",
|
||||
"statusComingSoon": "Coming soon",
|
||||
"comingSoon": "Coming soon",
|
||||
@@ -604,28 +584,27 @@
|
||||
"apps": {
|
||||
"description": "Add tools to nanobot, then @ them in chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "Integration",
|
||||
"channelLabel": "Channel",
|
||||
"featureLabel": "Feature",
|
||||
"filterAll": "Ready",
|
||||
"filterPlugins": "Plugins",
|
||||
"filterCli": "Apps",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "Integrations",
|
||||
"enabledSummary": "{{count}} ready",
|
||||
"caption": "{{cli}} apps · {{mcp}} MCP tools",
|
||||
"caption": "{{cli}} apps · {{mcp}} integrations",
|
||||
"searchPlaceholder": "Search tools",
|
||||
"featured": "Tools",
|
||||
"mcpTools": "MCP tools",
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No tools match your search.",
|
||||
"emptyApps": "No apps available.",
|
||||
"emptyIntegrations": "No MCP tools available.",
|
||||
"emptyIntegrations": "No integrations available.",
|
||||
"emptyReady": "No tools are ready yet.",
|
||||
"clearSearch": "Clear search",
|
||||
"browseApps": "Browse apps",
|
||||
"browseIntegrations": "Browse MCP tools",
|
||||
"emptyIntegrationsHint": "Add a custom MCP server below.",
|
||||
"restartRequired": "Restart nanobot to apply updated apps and MCP tools."
|
||||
"browseIntegrations": "Browse integrations",
|
||||
"emptyIntegrationsHint": "Add a custom integration below.",
|
||||
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Connect chat apps, email, and WebUI to nanobot.",
|
||||
@@ -986,6 +965,10 @@
|
||||
"unarchive": "Unarchive",
|
||||
"showArchived": "Show archived",
|
||||
"hideArchived": "Hide archived",
|
||||
"select": "Select",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"deleteSelected": "Delete",
|
||||
"delete": "Delete",
|
||||
"newChat": "New topic",
|
||||
"groups": {
|
||||
@@ -1000,10 +983,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Delete this topic?",
|
||||
"titleMany": "Delete {{count}} topics and panes?",
|
||||
"description": "This action cannot be undone.",
|
||||
"descriptionMany": "This action cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete",
|
||||
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
||||
"automationsDescriptionMany": "Linked automations will also be deleted.",
|
||||
"moreAutomations": "+ {{count}} more",
|
||||
"confirmWithAutomations": "Delete",
|
||||
"schedule": {
|
||||
@@ -1399,6 +1385,26 @@
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Conversation workbench",
|
||||
"panes": "Panes",
|
||||
"panesInTab": "Panes in {{title}}",
|
||||
"dropPane": "Move {{pane}} into {{tab}}",
|
||||
"moveToTab": "Move to tab",
|
||||
"layout": "Pane layout",
|
||||
"addPane": "Add pane",
|
||||
"promotePane": "Make {{title}} the primary pane",
|
||||
"paneActions": "{{title}} pane actions",
|
||||
"detachPane": "Move {{title}} to its own topic",
|
||||
"composerAria": "Message {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Columns",
|
||||
"rows": "Rows",
|
||||
"grid": "Grid",
|
||||
"main-stack": "Main and stack",
|
||||
"monocle": "Monocle"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Dismiss",
|
||||
"close": "Close",
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
"filterInstalled": "Habilitados",
|
||||
"filterNotInstalled": "No habilitados",
|
||||
"searchPlaceholder": "Buscar preajustes MCP",
|
||||
"moreOptions": "Añadir servidor MCP",
|
||||
"moreOptionsSubtitle": "Conecta un servidor MCP personalizado o importa una configuración existente.",
|
||||
"moreOptions": "Más opciones de MCP",
|
||||
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
|
||||
"customTitle": "MCP personalizado",
|
||||
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
|
||||
"customAction": "Personalizado",
|
||||
@@ -513,14 +513,9 @@
|
||||
"serverName": "Nombre del servidor",
|
||||
"serverUrl": "URL",
|
||||
"transport": "Transporte",
|
||||
"authentication": "Autenticación",
|
||||
"authNone": "Ninguna",
|
||||
"authHeaders": "Encabezados",
|
||||
"command": "Comando",
|
||||
"args": "Argumentos JSON",
|
||||
"headers": "Encabezados JSON",
|
||||
"oauthAfterSave": "Guarda el servidor y selecciona Conectar para iniciar sesión.",
|
||||
"headersHelp": "Añade los encabezados de solicitud que utiliza este servidor.",
|
||||
"env": "Entorno JSON",
|
||||
"timeout": "Tiempo límite de herramienta",
|
||||
"advancedOptions": "Opciones avanzadas",
|
||||
@@ -547,21 +542,6 @@
|
||||
"keepExisting": "Déjalo en blanco para conservar el valor actual",
|
||||
"statusConfigured": "Configurado",
|
||||
"statusMissingCredentials": "Necesita clave",
|
||||
"connectingAccount": "Conectando {{name}}",
|
||||
"connectingLabel": "Conectando…",
|
||||
"continueSignIn": "Continuar inicio de sesión",
|
||||
"preparingSignIn": "Preparando un inicio de sesión seguro…",
|
||||
"openSignInToContinue": "Abre la página de inicio de sesión para continuar.",
|
||||
"finishSignInInBrowser": "Termina de iniciar sesión en la ventana del navegador.",
|
||||
"manualCallbackRequired": "Termina de iniciar sesión y pega la URL de devolución en nanobot.",
|
||||
"manualCallbackHelp": "Después de aprobar el acceso, la página de localhost no se cargará. Copia la URL completa de la barra de direcciones y pégala aquí.",
|
||||
"finishingConnection": "Finalizando la conexión…",
|
||||
"activatingTools": "Activando herramientas…",
|
||||
"connected": "Conectado.",
|
||||
"connectionFailed": "Error de conexión.",
|
||||
"connectionCancelled": "Conexión cancelada.",
|
||||
"reloadFailed": "Has iniciado sesión, pero nanobot no pudo conectar las herramientas. Prueba a reiniciar nanobot.",
|
||||
"oauthFailed": "No se pudo conectar. Intenta iniciar sesión de nuevo.",
|
||||
"statusMissingDependency": "Necesita dependencia",
|
||||
"statusComingSoon": "Próximamente",
|
||||
"comingSoon": "Próximamente",
|
||||
@@ -591,28 +571,27 @@
|
||||
"apps": {
|
||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||
"cliLabel": "Aplicación",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "Integración",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Función",
|
||||
"filterAll": "Listo",
|
||||
"filterPlugins": "Complementos",
|
||||
"filterCli": "Aplicaciones",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "Integraciones",
|
||||
"enabledSummary": "{{count}} listos",
|
||||
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
|
||||
"caption": "{{cli}} aplicaciones · {{mcp}} integraciones",
|
||||
"searchPlaceholder": "Buscar aplicaciones",
|
||||
"featured": "Herramientas",
|
||||
"mcpTools": "Herramientas MCP",
|
||||
"loading": "Cargando aplicaciones...",
|
||||
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
||||
"emptyApps": "No hay aplicaciones disponibles.",
|
||||
"emptyIntegrations": "No hay herramientas MCP disponibles.",
|
||||
"emptyIntegrations": "No hay integraciones disponibles.",
|
||||
"emptyReady": "Todavía no hay herramientas listas.",
|
||||
"clearSearch": "Borrar búsqueda",
|
||||
"browseApps": "Explorar aplicaciones",
|
||||
"browseIntegrations": "Explorar herramientas MCP",
|
||||
"emptyIntegrationsHint": "Añade un servidor MCP personalizado abajo.",
|
||||
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y herramientas MCP actualizadas."
|
||||
"browseIntegrations": "Explorar integraciones",
|
||||
"emptyIntegrationsHint": "Añade una integración personalizada abajo.",
|
||||
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
||||
@@ -973,6 +952,10 @@
|
||||
"unarchive": "Desarchivar",
|
||||
"showArchived": "Mostrar archivados",
|
||||
"hideArchived": "Ocultar archivados",
|
||||
"select": "Seleccionar",
|
||||
"cancelSelection": "Cancelar selección",
|
||||
"selectedCount": "{{count}} seleccionados",
|
||||
"deleteSelected": "Eliminar",
|
||||
"delete": "Eliminar",
|
||||
"newChat": "Nuevo tema",
|
||||
"groups": {
|
||||
@@ -987,10 +970,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "¿Eliminar este chat?",
|
||||
"titleMany": "¿Eliminar {{count}} chats y paneles?",
|
||||
"description": "Esta acción no se puede deshacer.",
|
||||
"descriptionMany": "Esta acción no se puede deshacer.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Eliminar",
|
||||
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
||||
"automationsDescriptionMany": "También se eliminarán las automatizaciones vinculadas.",
|
||||
"moreAutomations": "+ {{count}} más",
|
||||
"confirmWithAutomations": "Eliminar",
|
||||
"schedule": {
|
||||
@@ -1386,6 +1372,26 @@
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Área de conversaciones",
|
||||
"panes": "Paneles",
|
||||
"panesInTab": "Paneles de {{title}}",
|
||||
"dropPane": "Mover {{pane}} a {{tab}}",
|
||||
"moveToTab": "Mover a una pestaña",
|
||||
"layout": "Diseño de paneles",
|
||||
"addPane": "Añadir panel",
|
||||
"promotePane": "Convertir {{title}} en el panel principal",
|
||||
"paneActions": "Acciones del panel {{title}}",
|
||||
"detachPane": "Mover {{title}} a su propio tema",
|
||||
"composerAria": "Mensaje para {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Columnas",
|
||||
"rows": "Filas",
|
||||
"grid": "Cuadrícula",
|
||||
"main-stack": "Principal y pila",
|
||||
"monocle": "Monóculo"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Cerrar",
|
||||
"close": "Cerrar",
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
"filterInstalled": "Activés",
|
||||
"filterNotInstalled": "Non activés",
|
||||
"searchPlaceholder": "Rechercher des préréglages MCP",
|
||||
"moreOptions": "Ajouter un serveur MCP",
|
||||
"moreOptionsSubtitle": "Connectez un serveur MCP personnalisé ou importez une configuration existante.",
|
||||
"moreOptions": "Plus d'options MCP",
|
||||
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
|
||||
"customTitle": "MCP personnalisé",
|
||||
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
|
||||
"customAction": "Personnalisé",
|
||||
@@ -513,14 +513,9 @@
|
||||
"serverName": "Nom du serveur",
|
||||
"serverUrl": "URL",
|
||||
"transport": "Transport",
|
||||
"authentication": "Authentification",
|
||||
"authNone": "Aucune",
|
||||
"authHeaders": "En-têtes",
|
||||
"command": "Commande",
|
||||
"args": "Arguments JSON",
|
||||
"headers": "En-têtes JSON",
|
||||
"oauthAfterSave": "Enregistrez le serveur, puis sélectionnez Se connecter pour vous identifier.",
|
||||
"headersHelp": "Ajoutez les en-têtes de requête utilisés par ce serveur.",
|
||||
"env": "Environnement JSON",
|
||||
"timeout": "Délai d'outil",
|
||||
"advancedOptions": "Options avancées",
|
||||
@@ -547,21 +542,6 @@
|
||||
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
|
||||
"statusConfigured": "Configuré",
|
||||
"statusMissingCredentials": "Clé requise",
|
||||
"connectingAccount": "Connexion à {{name}}",
|
||||
"connectingLabel": "Connexion…",
|
||||
"continueSignIn": "Continuer la connexion",
|
||||
"preparingSignIn": "Préparation d’une connexion sécurisée…",
|
||||
"openSignInToContinue": "Ouvrez la page de connexion pour continuer.",
|
||||
"finishSignInInBrowser": "Terminez la connexion dans la fenêtre du navigateur.",
|
||||
"manualCallbackRequired": "Terminez la connexion, puis collez l’URL de rappel dans nanobot.",
|
||||
"manualCallbackHelp": "Après avoir autorisé l’accès, la page localhost ne se chargera pas. Copiez son URL complète depuis la barre d’adresse et collez-la ici.",
|
||||
"finishingConnection": "Finalisation de la connexion…",
|
||||
"activatingTools": "Activation des outils…",
|
||||
"connected": "Connecté.",
|
||||
"connectionFailed": "Échec de la connexion.",
|
||||
"connectionCancelled": "Connexion annulée.",
|
||||
"reloadFailed": "Connexion réussie, mais nanobot n’a pas pu activer les outils. Essayez de redémarrer nanobot.",
|
||||
"oauthFailed": "Connexion impossible. Essayez de vous reconnecter.",
|
||||
"statusMissingDependency": "Dépendance requise",
|
||||
"statusComingSoon": "Bientôt disponible",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
@@ -590,28 +570,27 @@
|
||||
"apps": {
|
||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||
"cliLabel": "Application",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "Intégration",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Fonction",
|
||||
"filterAll": "Prêts",
|
||||
"filterPlugins": "Extensions",
|
||||
"filterCli": "Applications",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "Intégrations",
|
||||
"enabledSummary": "{{count}} prêts",
|
||||
"caption": "{{cli}} applications · {{mcp}} outils MCP",
|
||||
"caption": "{{cli}} applications · {{mcp}} intégrations",
|
||||
"searchPlaceholder": "Rechercher des applications",
|
||||
"featured": "Outils",
|
||||
"mcpTools": "Outils MCP",
|
||||
"loading": "Chargement des applications...",
|
||||
"empty": "Aucun outil ne correspond à votre recherche.",
|
||||
"emptyApps": "Aucune application disponible.",
|
||||
"emptyIntegrations": "Aucun outil MCP disponible.",
|
||||
"emptyIntegrations": "Aucune intégration disponible.",
|
||||
"emptyReady": "Aucun outil n’est encore prêt.",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"browseApps": "Parcourir les applications",
|
||||
"browseIntegrations": "Parcourir les outils MCP",
|
||||
"emptyIntegrationsHint": "Ajoutez un serveur MCP personnalisé ci-dessous.",
|
||||
"restartRequired": "Redémarrez nanobot pour appliquer les applications et outils MCP mis à jour."
|
||||
"browseIntegrations": "Parcourir les intégrations",
|
||||
"emptyIntegrationsHint": "Ajoutez une intégration personnalisée ci-dessous.",
|
||||
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
|
||||
@@ -972,6 +951,10 @@
|
||||
"unarchive": "Désarchiver",
|
||||
"showArchived": "Afficher les archives",
|
||||
"hideArchived": "Masquer les archives",
|
||||
"select": "Sélectionner",
|
||||
"cancelSelection": "Annuler la sélection",
|
||||
"selectedCount": "{{count}} sélectionnés",
|
||||
"deleteSelected": "Supprimer",
|
||||
"delete": "Supprimer",
|
||||
"newChat": "Nouveau sujet",
|
||||
"groups": {
|
||||
@@ -986,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer cette discussion ?",
|
||||
"titleMany": "Supprimer {{count}} discussions et volets ?",
|
||||
"description": "Cette action est irréversible.",
|
||||
"descriptionMany": "Cette action est irréversible.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer",
|
||||
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
||||
"automationsDescriptionMany": "Les automatisations liées seront également supprimées.",
|
||||
"moreAutomations": "+ {{count}} autres",
|
||||
"confirmWithAutomations": "Supprimer",
|
||||
"schedule": {
|
||||
@@ -1385,6 +1371,26 @@
|
||||
"copy": "Copier",
|
||||
"copied": "Copié"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Espace de conversations",
|
||||
"panes": "Volets",
|
||||
"panesInTab": "Volets dans {{title}}",
|
||||
"dropPane": "Déplacer {{pane}} dans {{tab}}",
|
||||
"moveToTab": "Déplacer vers un onglet",
|
||||
"layout": "Disposition des volets",
|
||||
"addPane": "Ajouter un volet",
|
||||
"promotePane": "Définir {{title}} comme volet principal",
|
||||
"paneActions": "Actions du volet {{title}}",
|
||||
"detachPane": "Déplacer {{title}} vers son propre sujet",
|
||||
"composerAria": "Message à {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Colonnes",
|
||||
"rows": "Lignes",
|
||||
"grid": "Grille",
|
||||
"main-stack": "Principal et pile",
|
||||
"monocle": "Monocle"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Fermer",
|
||||
"close": "Fermer",
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
"filterInstalled": "Aktif",
|
||||
"filterNotInstalled": "Tidak aktif",
|
||||
"searchPlaceholder": "Cari prasetel MCP",
|
||||
"moreOptions": "Tambahkan server MCP",
|
||||
"moreOptionsSubtitle": "Hubungkan server MCP khusus atau impor konfigurasi yang ada.",
|
||||
"moreOptions": "Opsi MCP lainnya",
|
||||
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
|
||||
"customTitle": "MCP khusus",
|
||||
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
|
||||
"customAction": "Khusus",
|
||||
@@ -513,14 +513,9 @@
|
||||
"serverName": "Nama server",
|
||||
"serverUrl": "URL",
|
||||
"transport": "Transport",
|
||||
"authentication": "Autentikasi",
|
||||
"authNone": "Tidak ada",
|
||||
"authHeaders": "Header",
|
||||
"command": "Perintah",
|
||||
"args": "Argumen JSON",
|
||||
"headers": "Header JSON",
|
||||
"oauthAfterSave": "Simpan server, lalu pilih Hubungkan untuk masuk.",
|
||||
"headersHelp": "Tambahkan header permintaan yang digunakan server ini.",
|
||||
"env": "Lingkungan JSON",
|
||||
"timeout": "Batas waktu alat",
|
||||
"advancedOptions": "Opsi lanjutan",
|
||||
@@ -547,21 +542,6 @@
|
||||
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
|
||||
"statusConfigured": "Terkonfigurasi",
|
||||
"statusMissingCredentials": "Butuh kunci",
|
||||
"connectingAccount": "Menghubungkan {{name}}",
|
||||
"connectingLabel": "Menghubungkan…",
|
||||
"continueSignIn": "Lanjutkan masuk",
|
||||
"preparingSignIn": "Menyiapkan proses masuk yang aman…",
|
||||
"openSignInToContinue": "Buka halaman masuk untuk melanjutkan.",
|
||||
"finishSignInInBrowser": "Selesaikan proses masuk di jendela browser.",
|
||||
"manualCallbackRequired": "Selesaikan proses masuk, lalu tempel URL callback ke nanobot.",
|
||||
"manualCallbackHelp": "Setelah menyetujui akses, halaman localhost tidak akan terbuka. Salin URL lengkap dari bilah alamat lalu tempel di sini.",
|
||||
"finishingConnection": "Menyelesaikan koneksi…",
|
||||
"activatingTools": "Mengaktifkan alat…",
|
||||
"connected": "Terhubung.",
|
||||
"connectionFailed": "Koneksi gagal.",
|
||||
"connectionCancelled": "Koneksi dibatalkan.",
|
||||
"reloadFailed": "Anda sudah masuk, tetapi nanobot tidak dapat menghubungkan alat. Coba mulai ulang nanobot.",
|
||||
"oauthFailed": "Tidak dapat terhubung. Coba masuk lagi.",
|
||||
"statusMissingDependency": "Butuh dependensi",
|
||||
"statusComingSoon": "Segera hadir",
|
||||
"comingSoon": "Segera hadir",
|
||||
@@ -590,28 +570,27 @@
|
||||
"apps": {
|
||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||
"cliLabel": "Aplikasi",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "Integrasi",
|
||||
"channelLabel": "Kanal",
|
||||
"featureLabel": "Fitur",
|
||||
"filterAll": "Siap",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Aplikasi",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "Integrasi",
|
||||
"enabledSummary": "{{count}} siap",
|
||||
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
|
||||
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Alat",
|
||||
"mcpTools": "Alat MCP",
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
||||
"emptyApps": "Tidak ada aplikasi yang tersedia.",
|
||||
"emptyIntegrations": "Tidak ada alat MCP yang tersedia.",
|
||||
"emptyIntegrations": "Tidak ada integrasi yang tersedia.",
|
||||
"emptyReady": "Belum ada alat yang siap.",
|
||||
"clearSearch": "Hapus pencarian",
|
||||
"browseApps": "Jelajahi aplikasi",
|
||||
"browseIntegrations": "Jelajahi alat MCP",
|
||||
"emptyIntegrationsHint": "Tambahkan server MCP khusus di bawah.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan alat MCP yang diperbarui."
|
||||
"browseIntegrations": "Jelajahi integrasi",
|
||||
"emptyIntegrationsHint": "Tambahkan integrasi khusus di bawah.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
|
||||
@@ -972,6 +951,10 @@
|
||||
"unarchive": "Batalkan arsip",
|
||||
"showArchived": "Tampilkan yang diarsipkan",
|
||||
"hideArchived": "Sembunyikan yang diarsipkan",
|
||||
"select": "Pilih",
|
||||
"cancelSelection": "Batalkan pilihan",
|
||||
"selectedCount": "{{count}} dipilih",
|
||||
"deleteSelected": "Hapus",
|
||||
"delete": "Hapus",
|
||||
"newChat": "Topik baru",
|
||||
"groups": {
|
||||
@@ -986,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Hapus obrolan ini?",
|
||||
"titleMany": "Hapus {{count}} obrolan dan panel?",
|
||||
"description": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"descriptionMany": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"cancel": "Batal",
|
||||
"confirm": "Hapus",
|
||||
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
||||
"automationsDescriptionMany": "Automasi terkait juga akan dihapus.",
|
||||
"moreAutomations": "+ {{count}} lagi",
|
||||
"confirmWithAutomations": "Hapus",
|
||||
"schedule": {
|
||||
@@ -1385,6 +1371,26 @@
|
||||
"copy": "Salin",
|
||||
"copied": "Tersalin"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Ruang kerja percakapan",
|
||||
"panes": "Panel",
|
||||
"panesInTab": "Panel di {{title}}",
|
||||
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
|
||||
"moveToTab": "Pindahkan ke tab",
|
||||
"layout": "Tata letak panel",
|
||||
"addPane": "Tambah panel",
|
||||
"promotePane": "Jadikan {{title}} panel utama",
|
||||
"paneActions": "Tindakan panel {{title}}",
|
||||
"detachPane": "Pindahkan {{title}} ke topik tersendiri",
|
||||
"composerAria": "Pesan untuk {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Kolom",
|
||||
"rows": "Baris",
|
||||
"grid": "Kisi",
|
||||
"main-stack": "Utama dan tumpukan",
|
||||
"monocle": "Panel tunggal"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Tutup",
|
||||
"close": "Tutup",
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
"filterInstalled": "有効",
|
||||
"filterNotInstalled": "未有効",
|
||||
"searchPlaceholder": "MCP プリセットを検索",
|
||||
"moreOptions": "MCP サーバーを追加",
|
||||
"moreOptionsSubtitle": "カスタム MCP サーバーを接続するか、既存の設定をインポートします。",
|
||||
"moreOptions": "その他の MCP オプション",
|
||||
"moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。",
|
||||
"customTitle": "カスタム MCP",
|
||||
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
|
||||
"customAction": "カスタム",
|
||||
@@ -513,14 +513,9 @@
|
||||
"serverName": "サーバー名",
|
||||
"serverUrl": "URL",
|
||||
"transport": "トランスポート",
|
||||
"authentication": "認証",
|
||||
"authNone": "なし",
|
||||
"authHeaders": "ヘッダー",
|
||||
"command": "コマンド",
|
||||
"args": "引数 JSON",
|
||||
"headers": "ヘッダー JSON",
|
||||
"oauthAfterSave": "サーバーを保存してから、[接続]を選択してサインインします。",
|
||||
"headersHelp": "このサーバーで使用するリクエストヘッダーを追加します。",
|
||||
"env": "環境変数 JSON",
|
||||
"timeout": "ツールのタイムアウト",
|
||||
"advancedOptions": "詳細オプション",
|
||||
@@ -547,21 +542,6 @@
|
||||
"keepExisting": "既存の値を維持するには空欄のままにします",
|
||||
"statusConfigured": "設定済み",
|
||||
"statusMissingCredentials": "キーが必要",
|
||||
"connectingAccount": "{{name}} に接続しています",
|
||||
"connectingLabel": "接続中…",
|
||||
"continueSignIn": "サインインを続ける",
|
||||
"preparingSignIn": "安全なサインインを準備しています…",
|
||||
"openSignInToContinue": "サインインページを開いて続行してください。",
|
||||
"finishSignInInBrowser": "ブラウザウィンドウでサインインを完了してください。",
|
||||
"manualCallbackRequired": "サインインを完了し、コールバック URL を nanobot に貼り付けてください。",
|
||||
"manualCallbackHelp": "アクセスを承認すると localhost ページは開きません。アドレスバーから完全な URL をコピーして、ここに貼り付けてください。",
|
||||
"finishingConnection": "接続を完了しています…",
|
||||
"activatingTools": "ツールを有効にしています…",
|
||||
"connected": "接続しました。",
|
||||
"connectionFailed": "接続に失敗しました。",
|
||||
"connectionCancelled": "接続をキャンセルしました。",
|
||||
"reloadFailed": "サインインしましたが、nanobot はツールに接続できませんでした。nanobot を再起動してください。",
|
||||
"oauthFailed": "接続できません。もう一度サインインしてください。",
|
||||
"statusMissingDependency": "依存関係が必要",
|
||||
"statusComingSoon": "近日公開",
|
||||
"comingSoon": "近日公開",
|
||||
@@ -590,28 +570,27 @@
|
||||
"apps": {
|
||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||
"cliLabel": "アプリ",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "連携",
|
||||
"channelLabel": "チャンネル",
|
||||
"featureLabel": "機能",
|
||||
"filterAll": "使用可能",
|
||||
"filterPlugins": "プラグイン",
|
||||
"filterCli": "アプリ",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "連携",
|
||||
"enabledSummary": "{{count}} 件使用可能",
|
||||
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
|
||||
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "ツール",
|
||||
"mcpTools": "MCP ツール",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "検索条件に一致するツールはありません。",
|
||||
"emptyApps": "利用できるアプリはありません。",
|
||||
"emptyIntegrations": "利用できる MCP ツールはありません。",
|
||||
"emptyIntegrations": "利用できる連携はありません。",
|
||||
"emptyReady": "使用可能なツールはまだありません。",
|
||||
"clearSearch": "検索をクリア",
|
||||
"browseApps": "アプリを見る",
|
||||
"browseIntegrations": "MCP ツールを見る",
|
||||
"emptyIntegrationsHint": "下からカスタム MCP サーバーを追加できます。",
|
||||
"restartRequired": "更新したアプリと MCP ツールを反映するには nanobot を再起動してください。"
|
||||
"browseIntegrations": "連携を見る",
|
||||
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
|
||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
||||
@@ -972,6 +951,10 @@
|
||||
"unarchive": "アーカイブを解除",
|
||||
"showArchived": "アーカイブ済みを表示",
|
||||
"hideArchived": "アーカイブ済みを隠す",
|
||||
"select": "選択",
|
||||
"cancelSelection": "選択を解除",
|
||||
"selectedCount": "{{count}} 件を選択中",
|
||||
"deleteSelected": "削除",
|
||||
"delete": "削除",
|
||||
"newChat": "新しいトピック",
|
||||
"groups": {
|
||||
@@ -986,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "このチャットを削除しますか?",
|
||||
"titleMany": "{{count}} 件のチャットとペインを削除しますか?",
|
||||
"description": "この操作は元に戻せません。",
|
||||
"descriptionMany": "この操作は元に戻せません。",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "削除",
|
||||
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
||||
"automationsDescriptionMany": "関連する自動タスクも削除されます。",
|
||||
"moreAutomations": "他 {{count}} 件",
|
||||
"confirmWithAutomations": "削除",
|
||||
"schedule": {
|
||||
@@ -1385,6 +1371,26 @@
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "会話ワークベンチ",
|
||||
"panes": "ペイン",
|
||||
"panesInTab": "{{title}} のペイン",
|
||||
"dropPane": "{{pane}} を {{tab}} に移動",
|
||||
"moveToTab": "タブへ移動",
|
||||
"layout": "ペインレイアウト",
|
||||
"addPane": "ペインを追加",
|
||||
"promotePane": "{{title}} をメインペインにする",
|
||||
"paneActions": "{{title}} ペインの操作",
|
||||
"detachPane": "{{title}} を独立したトピックに移動",
|
||||
"composerAria": "{{title}} へのメッセージ",
|
||||
"layouts": {
|
||||
"columns": "列",
|
||||
"rows": "行",
|
||||
"grid": "グリッド",
|
||||
"main-stack": "メインとスタック",
|
||||
"monocle": "モノクル"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "閉じる",
|
||||
"close": "閉じる",
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
"filterInstalled": "활성화됨",
|
||||
"filterNotInstalled": "비활성",
|
||||
"searchPlaceholder": "MCP 프리셋 검색",
|
||||
"moreOptions": "MCP 서버 추가",
|
||||
"moreOptionsSubtitle": "사용자 지정 MCP 서버를 연결하거나 기존 구성을 가져옵니다.",
|
||||
"moreOptions": "추가 MCP 옵션",
|
||||
"moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.",
|
||||
"customTitle": "사용자 지정 MCP",
|
||||
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
|
||||
"customAction": "사용자 지정",
|
||||
@@ -513,14 +513,9 @@
|
||||
"serverName": "서버 이름",
|
||||
"serverUrl": "URL",
|
||||
"transport": "전송 방식",
|
||||
"authentication": "인증",
|
||||
"authNone": "없음",
|
||||
"authHeaders": "헤더",
|
||||
"command": "명령",
|
||||
"args": "인자 JSON",
|
||||
"headers": "헤더 JSON",
|
||||
"oauthAfterSave": "서버를 저장한 다음 연결을 선택하여 로그인하세요.",
|
||||
"headersHelp": "이 서버에서 사용하는 요청 헤더를 추가하세요.",
|
||||
"env": "환경 변수 JSON",
|
||||
"timeout": "도구 제한 시간",
|
||||
"advancedOptions": "고급 옵션",
|
||||
@@ -547,21 +542,6 @@
|
||||
"keepExisting": "기존 값을 유지하려면 비워 두세요",
|
||||
"statusConfigured": "구성됨",
|
||||
"statusMissingCredentials": "키 필요",
|
||||
"connectingAccount": "{{name}} 연결 중",
|
||||
"connectingLabel": "연결 중…",
|
||||
"continueSignIn": "로그인 계속",
|
||||
"preparingSignIn": "안전한 로그인을 준비하는 중…",
|
||||
"openSignInToContinue": "계속하려면 로그인 페이지를 여세요.",
|
||||
"finishSignInInBrowser": "브라우저 창에서 로그인을 완료하세요.",
|
||||
"manualCallbackRequired": "로그인을 완료한 다음 콜백 URL을 nanobot에 붙여 넣으세요.",
|
||||
"manualCallbackHelp": "접근을 승인하면 localhost 페이지가 열리지 않습니다. 주소 표시줄에서 전체 URL을 복사해 여기에 붙여 넣으세요.",
|
||||
"finishingConnection": "연결을 마무리하는 중…",
|
||||
"activatingTools": "도구를 활성화하는 중…",
|
||||
"connected": "연결됨.",
|
||||
"connectionFailed": "연결에 실패했습니다.",
|
||||
"connectionCancelled": "연결을 취소했습니다.",
|
||||
"reloadFailed": "로그인했지만 nanobot에서 도구를 연결하지 못했습니다. nanobot을 다시 시작해 보세요.",
|
||||
"oauthFailed": "연결할 수 없습니다. 다시 로그인해 보세요.",
|
||||
"statusMissingDependency": "의존성 필요",
|
||||
"statusComingSoon": "곧 제공",
|
||||
"comingSoon": "곧 제공",
|
||||
@@ -590,28 +570,27 @@
|
||||
"apps": {
|
||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||
"cliLabel": "앱",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "연동",
|
||||
"channelLabel": "채널",
|
||||
"featureLabel": "기능",
|
||||
"filterAll": "사용 가능",
|
||||
"filterPlugins": "플러그인",
|
||||
"filterCli": "앱",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "연동",
|
||||
"enabledSummary": "{{count}}개 사용 가능",
|
||||
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
|
||||
"caption": "앱 {{cli}}개 · 연동 {{mcp}}개",
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "도구",
|
||||
"mcpTools": "MCP 도구",
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "검색과 일치하는 도구가 없습니다.",
|
||||
"emptyApps": "사용 가능한 앱이 없습니다.",
|
||||
"emptyIntegrations": "사용 가능한 MCP 도구가 없습니다.",
|
||||
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
|
||||
"emptyReady": "아직 준비된 도구가 없습니다.",
|
||||
"clearSearch": "검색 지우기",
|
||||
"browseApps": "앱 둘러보기",
|
||||
"browseIntegrations": "MCP 도구 둘러보기",
|
||||
"emptyIntegrationsHint": "아래에서 사용자 지정 MCP 서버를 추가하세요.",
|
||||
"restartRequired": "업데이트된 앱과 MCP 도구를 적용하려면 nanobot을 다시 시작하세요."
|
||||
"browseIntegrations": "연동 둘러보기",
|
||||
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
|
||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
||||
},
|
||||
"channels": {
|
||||
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
||||
@@ -972,6 +951,10 @@
|
||||
"unarchive": "보관 해제",
|
||||
"showArchived": "보관된 항목 표시",
|
||||
"hideArchived": "보관된 항목 숨기기",
|
||||
"select": "선택",
|
||||
"cancelSelection": "선택 취소",
|
||||
"selectedCount": "{{count}}개 선택됨",
|
||||
"deleteSelected": "삭제",
|
||||
"delete": "삭제",
|
||||
"newChat": "새 주제",
|
||||
"groups": {
|
||||
@@ -986,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "이 채팅을 삭제할까요?",
|
||||
"titleMany": "채팅과 창 {{count}}개를 삭제할까요?",
|
||||
"description": "이 작업은 되돌릴 수 없습니다.",
|
||||
"descriptionMany": "이 작업은 되돌릴 수 없습니다.",
|
||||
"cancel": "취소",
|
||||
"confirm": "삭제",
|
||||
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
||||
"automationsDescriptionMany": "연결된 자동화도 함께 삭제됩니다.",
|
||||
"moreAutomations": "+ {{count}}개 더",
|
||||
"confirmWithAutomations": "삭제",
|
||||
"schedule": {
|
||||
@@ -1385,6 +1371,26 @@
|
||||
"copy": "복사",
|
||||
"copied": "복사됨"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "대화 워크벤치",
|
||||
"panes": "창",
|
||||
"panesInTab": "{{title}}의 창",
|
||||
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
|
||||
"moveToTab": "탭으로 이동",
|
||||
"layout": "창 레이아웃",
|
||||
"addPane": "창 추가",
|
||||
"promotePane": "{{title}}을(를) 기본 창으로 설정",
|
||||
"paneActions": "{{title}} 창 작업",
|
||||
"detachPane": "{{title}}을(를) 별도 주제로 이동",
|
||||
"composerAria": "{{title}}에 메시지 보내기",
|
||||
"layouts": {
|
||||
"columns": "열",
|
||||
"rows": "행",
|
||||
"grid": "그리드",
|
||||
"main-stack": "기본 창과 스택",
|
||||
"monocle": "단일 창"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "닫기",
|
||||
"close": "닫기",
|
||||
|
||||
@@ -319,8 +319,8 @@
|
||||
"filterInstalled": "Habilitadas",
|
||||
"filterNotInstalled": "Não habilitadas",
|
||||
"searchPlaceholder": "Buscar predefinições MCP",
|
||||
"moreOptions": "Adicionar servidor MCP",
|
||||
"moreOptionsSubtitle": "Conecte um servidor MCP personalizado ou importe uma configuração existente.",
|
||||
"moreOptions": "Adicionar integração",
|
||||
"moreOptionsSubtitle": "Conecte um servidor de ferramentas personalizado ou importe uma configuração existente.",
|
||||
"customTitle": "MCP personalizado",
|
||||
"customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.",
|
||||
"customAction": "Personalizado",
|
||||
@@ -328,14 +328,9 @@
|
||||
"serverName": "Nome do servidor",
|
||||
"serverUrl": "URL",
|
||||
"transport": "Transporte",
|
||||
"authentication": "Autenticação",
|
||||
"authNone": "Nenhuma",
|
||||
"authHeaders": "Cabeçalhos",
|
||||
"command": "Comando",
|
||||
"args": "Argumentos JSON",
|
||||
"headers": "Cabeçalhos JSON",
|
||||
"oauthAfterSave": "Salve o servidor e selecione Conectar para entrar.",
|
||||
"headersHelp": "Adicione os cabeçalhos de solicitação usados por este servidor.",
|
||||
"env": "Ambiente JSON",
|
||||
"timeout": "Tempo limite da ferramenta",
|
||||
"advancedOptions": "Opções avançadas",
|
||||
@@ -362,21 +357,6 @@
|
||||
"keepExisting": "Deixe em branco para manter o valor atual",
|
||||
"statusConfigured": "Configurado",
|
||||
"statusMissingCredentials": "Precisa de chave",
|
||||
"connectingAccount": "Conectando {{name}}",
|
||||
"connectingLabel": "Conectando…",
|
||||
"continueSignIn": "Continuar login",
|
||||
"preparingSignIn": "Preparando login seguro…",
|
||||
"openSignInToContinue": "Abra a página de login para continuar.",
|
||||
"finishSignInInBrowser": "Conclua o login na janela do navegador.",
|
||||
"manualCallbackRequired": "Conclua o login e cole a URL de callback no nanobot.",
|
||||
"manualCallbackHelp": "Depois de autorizar o acesso, a página localhost não será carregada. Copie a URL completa da barra de endereços e cole-a aqui.",
|
||||
"finishingConnection": "Finalizando conexão…",
|
||||
"activatingTools": "Ativando ferramentas…",
|
||||
"connected": "Conectado.",
|
||||
"connectionFailed": "Falha na conexão.",
|
||||
"connectionCancelled": "Conexão cancelada.",
|
||||
"reloadFailed": "Login concluído, mas o nanobot não conseguiu conectar as ferramentas. Tente reiniciar o nanobot.",
|
||||
"oauthFailed": "Não foi possível conectar. Tente fazer login novamente.",
|
||||
"statusMissingDependency": "Precisa de dependência",
|
||||
"statusComingSoon": "Em breve",
|
||||
"comingSoon": "Em breve",
|
||||
@@ -604,28 +584,27 @@
|
||||
"apps": {
|
||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||
"cliLabel": "Aplicativo",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "Integração",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Recurso",
|
||||
"filterAll": "Prontos",
|
||||
"filterPlugins": "Complementos",
|
||||
"filterCli": "Aplicativos",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "Integrações",
|
||||
"enabledSummary": "{{count}} prontos",
|
||||
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
|
||||
"caption": "{{cli}} aplicativos · {{mcp}} integrações",
|
||||
"searchPlaceholder": "Buscar ferramentas",
|
||||
"featured": "Ferramentas",
|
||||
"mcpTools": "Ferramentas MCP",
|
||||
"loading": "Carregando aplicativos...",
|
||||
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
||||
"emptyApps": "Nenhum aplicativo disponível.",
|
||||
"emptyIntegrations": "Nenhuma ferramenta MCP disponível.",
|
||||
"emptyIntegrations": "Nenhuma integração disponível.",
|
||||
"emptyReady": "Ainda não há ferramentas prontas.",
|
||||
"clearSearch": "Limpar busca",
|
||||
"browseApps": "Explorar aplicativos",
|
||||
"browseIntegrations": "Explorar ferramentas MCP",
|
||||
"emptyIntegrationsHint": "Adicione um servidor MCP personalizado abaixo.",
|
||||
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e ferramentas MCP atualizados."
|
||||
"browseIntegrations": "Explorar integrações",
|
||||
"emptyIntegrationsHint": "Adicione uma integração personalizada abaixo.",
|
||||
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
|
||||
@@ -986,6 +965,10 @@
|
||||
"unarchive": "Desarquivar",
|
||||
"showArchived": "Mostrar arquivadas",
|
||||
"hideArchived": "Ocultar arquivadas",
|
||||
"select": "Selecionar",
|
||||
"cancelSelection": "Cancelar seleção",
|
||||
"selectedCount": "{{count}} selecionados",
|
||||
"deleteSelected": "Excluir",
|
||||
"delete": "Excluir",
|
||||
"newChat": "Novo tópico",
|
||||
"groups": {
|
||||
@@ -1000,10 +983,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Excluir esta conversa?",
|
||||
"titleMany": "Excluir {{count}} conversas e painéis?",
|
||||
"description": "Esta ação não pode ser desfeita.",
|
||||
"descriptionMany": "Esta ação não pode ser desfeita.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Excluir",
|
||||
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.",
|
||||
"automationsDescriptionMany": "As automações vinculadas também serão excluídas.",
|
||||
"moreAutomations": "+ {{count}} a mais",
|
||||
"confirmWithAutomations": "Excluir",
|
||||
"schedule": {
|
||||
@@ -1399,6 +1385,26 @@
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Área de conversas",
|
||||
"panes": "Painéis",
|
||||
"panesInTab": "Painéis em {{title}}",
|
||||
"dropPane": "Mover {{pane}} para {{tab}}",
|
||||
"moveToTab": "Mover para uma aba",
|
||||
"layout": "Layout de painéis",
|
||||
"addPane": "Adicionar painel",
|
||||
"promotePane": "Tornar {{title}} o painel principal",
|
||||
"paneActions": "Ações do painel {{title}}",
|
||||
"detachPane": "Mover {{title}} para seu próprio tópico",
|
||||
"composerAria": "Mensagem para {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Colunas",
|
||||
"rows": "Linhas",
|
||||
"grid": "Grade",
|
||||
"main-stack": "Principal e pilha",
|
||||
"monocle": "Monóculo"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Descartar",
|
||||
"close": "Fechar",
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
"filterInstalled": "Đã bật",
|
||||
"filterNotInstalled": "Chưa bật",
|
||||
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
|
||||
"moreOptions": "Thêm máy chủ MCP",
|
||||
"moreOptionsSubtitle": "Kết nối máy chủ MCP tùy chỉnh hoặc nhập cấu hình hiện có.",
|
||||
"moreOptions": "Tùy chọn MCP khác",
|
||||
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
|
||||
"customTitle": "MCP tùy chỉnh",
|
||||
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
|
||||
"customAction": "Tùy chỉnh",
|
||||
@@ -513,14 +513,9 @@
|
||||
"serverName": "Tên máy chủ",
|
||||
"serverUrl": "URL",
|
||||
"transport": "Giao thức truyền",
|
||||
"authentication": "Xác thực",
|
||||
"authNone": "Không có",
|
||||
"authHeaders": "Header",
|
||||
"command": "Lệnh",
|
||||
"args": "Đối số JSON",
|
||||
"headers": "Header JSON",
|
||||
"oauthAfterSave": "Lưu máy chủ, sau đó chọn Kết nối để đăng nhập.",
|
||||
"headersHelp": "Thêm các header yêu cầu mà máy chủ này sử dụng.",
|
||||
"env": "Môi trường JSON",
|
||||
"timeout": "Thời gian chờ công cụ",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
@@ -547,21 +542,6 @@
|
||||
"keepExisting": "Để trống để giữ giá trị hiện tại",
|
||||
"statusConfigured": "Đã cấu hình",
|
||||
"statusMissingCredentials": "Cần khóa",
|
||||
"connectingAccount": "Đang kết nối {{name}}",
|
||||
"connectingLabel": "Đang kết nối…",
|
||||
"continueSignIn": "Tiếp tục đăng nhập",
|
||||
"preparingSignIn": "Đang chuẩn bị đăng nhập an toàn…",
|
||||
"openSignInToContinue": "Mở trang đăng nhập để tiếp tục.",
|
||||
"finishSignInInBrowser": "Hoàn tất đăng nhập trong cửa sổ trình duyệt.",
|
||||
"manualCallbackRequired": "Hoàn tất đăng nhập, rồi dán URL callback vào nanobot.",
|
||||
"manualCallbackHelp": "Sau khi phê duyệt quyền truy cập, trang localhost sẽ không tải được. Hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào đây.",
|
||||
"finishingConnection": "Đang hoàn tất kết nối…",
|
||||
"activatingTools": "Đang kích hoạt công cụ…",
|
||||
"connected": "Đã kết nối.",
|
||||
"connectionFailed": "Kết nối thất bại.",
|
||||
"connectionCancelled": "Đã hủy kết nối.",
|
||||
"reloadFailed": "Đã đăng nhập nhưng nanobot không thể kết nối các công cụ. Hãy thử khởi động lại nanobot.",
|
||||
"oauthFailed": "Không thể kết nối. Hãy thử đăng nhập lại.",
|
||||
"statusMissingDependency": "Cần phụ thuộc",
|
||||
"statusComingSoon": "Sắp ra mắt",
|
||||
"comingSoon": "Sắp ra mắt",
|
||||
@@ -590,28 +570,27 @@
|
||||
"apps": {
|
||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||
"cliLabel": "Ứng dụng",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "Tích hợp",
|
||||
"channelLabel": "Kênh",
|
||||
"featureLabel": "Tính năng",
|
||||
"filterAll": "Sẵn sàng",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Ứng dụng",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "Tích hợp",
|
||||
"enabledSummary": "{{count}} sẵn sàng",
|
||||
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
|
||||
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Công cụ",
|
||||
"mcpTools": "Công cụ MCP",
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
||||
"emptyApps": "Không có ứng dụng nào.",
|
||||
"emptyIntegrations": "Không có công cụ MCP nào.",
|
||||
"emptyIntegrations": "Không có tích hợp nào.",
|
||||
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
|
||||
"clearSearch": "Xóa tìm kiếm",
|
||||
"browseApps": "Xem ứng dụng",
|
||||
"browseIntegrations": "Xem công cụ MCP",
|
||||
"emptyIntegrationsHint": "Thêm máy chủ MCP tùy chỉnh ở bên dưới.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và công cụ MCP đã cập nhật."
|
||||
"browseIntegrations": "Xem tích hợp",
|
||||
"emptyIntegrationsHint": "Thêm tích hợp tùy chỉnh ở bên dưới.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
|
||||
@@ -972,6 +951,10 @@
|
||||
"unarchive": "Bỏ lưu trữ",
|
||||
"showArchived": "Hiện mục đã lưu trữ",
|
||||
"hideArchived": "Ẩn mục đã lưu trữ",
|
||||
"select": "Chọn",
|
||||
"cancelSelection": "Hủy chọn",
|
||||
"selectedCount": "Đã chọn {{count}} mục",
|
||||
"deleteSelected": "Xóa",
|
||||
"delete": "Xóa",
|
||||
"newChat": "Chủ đề mới",
|
||||
"groups": {
|
||||
@@ -986,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Xóa cuộc trò chuyện này?",
|
||||
"titleMany": "Xóa {{count}} cuộc trò chuyện và khung?",
|
||||
"description": "Không thể hoàn tác thao tác này.",
|
||||
"descriptionMany": "Không thể hoàn tác thao tác này.",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xóa",
|
||||
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
||||
"automationsDescriptionMany": "Các tự động hóa liên kết cũng sẽ bị xóa.",
|
||||
"moreAutomations": "+ {{count}} mục nữa",
|
||||
"confirmWithAutomations": "Xóa",
|
||||
"schedule": {
|
||||
@@ -1385,6 +1371,26 @@
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "Không gian hội thoại",
|
||||
"panes": "Khung",
|
||||
"panesInTab": "Các khung trong {{title}}",
|
||||
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
|
||||
"moveToTab": "Di chuyển vào thẻ",
|
||||
"layout": "Bố cục khung",
|
||||
"addPane": "Thêm khung",
|
||||
"promotePane": "Đặt {{title}} làm khung chính",
|
||||
"paneActions": "Thao tác cho khung {{title}}",
|
||||
"detachPane": "Chuyển {{title}} thành chủ đề riêng",
|
||||
"composerAria": "Nhắn tin cho {{title}}",
|
||||
"layouts": {
|
||||
"columns": "Cột",
|
||||
"rows": "Hàng",
|
||||
"grid": "Lưới",
|
||||
"main-stack": "Khung chính và ngăn xếp",
|
||||
"monocle": "Một khung"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "Đóng",
|
||||
"close": "Đóng",
|
||||
|
||||
@@ -319,8 +319,8 @@
|
||||
"filterInstalled": "已启用",
|
||||
"filterNotInstalled": "未启用",
|
||||
"searchPlaceholder": "搜索 MCP 预设",
|
||||
"moreOptions": "添加 MCP 服务",
|
||||
"moreOptionsSubtitle": "连接自定义 MCP 服务,或导入已有配置。",
|
||||
"moreOptions": "添加集成",
|
||||
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
|
||||
"customTitle": "自定义 MCP",
|
||||
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
|
||||
"customAction": "自定义",
|
||||
@@ -328,14 +328,9 @@
|
||||
"serverName": "服务名",
|
||||
"serverUrl": "URL",
|
||||
"transport": "传输方式",
|
||||
"authentication": "身份验证",
|
||||
"authNone": "无",
|
||||
"authHeaders": "请求头",
|
||||
"command": "命令",
|
||||
"args": "Args JSON",
|
||||
"headers": "请求头 JSON",
|
||||
"oauthAfterSave": "保存服务器后,选择“连接”以完成登录。",
|
||||
"headersHelp": "添加此服务器要求的请求头。",
|
||||
"headers": "Headers JSON",
|
||||
"env": "Env JSON",
|
||||
"timeout": "工具超时",
|
||||
"advancedOptions": "高级选项",
|
||||
@@ -362,21 +357,6 @@
|
||||
"keepExisting": "留空则保留当前值",
|
||||
"statusConfigured": "已配置",
|
||||
"statusMissingCredentials": "需要密钥",
|
||||
"connectingAccount": "正在连接 {{name}}",
|
||||
"connectingLabel": "正在连接…",
|
||||
"continueSignIn": "继续登录",
|
||||
"preparingSignIn": "正在准备安全登录…",
|
||||
"openSignInToContinue": "打开登录页面以继续。",
|
||||
"finishSignInInBrowser": "请在浏览器窗口中完成登录。",
|
||||
"manualCallbackRequired": "完成登录后,将回调 URL 粘贴到 nanobot。",
|
||||
"manualCallbackHelp": "授权后,localhost 页面将无法打开。请复制地址栏中的完整 URL 并粘贴到这里。",
|
||||
"finishingConnection": "正在完成连接…",
|
||||
"activatingTools": "正在启用工具…",
|
||||
"connected": "已连接。",
|
||||
"connectionFailed": "连接失败。",
|
||||
"connectionCancelled": "已取消连接。",
|
||||
"reloadFailed": "已登录,但 nanobot 无法连接这些工具。请尝试重启 nanobot。",
|
||||
"oauthFailed": "无法连接,请重新登录。",
|
||||
"statusMissingDependency": "缺少依赖",
|
||||
"statusComingSoon": "暂不支持",
|
||||
"comingSoon": "即将推出",
|
||||
@@ -604,28 +584,27 @@
|
||||
"apps": {
|
||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||
"cliLabel": "应用",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "集成",
|
||||
"channelLabel": "渠道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "可用",
|
||||
"filterPlugins": "插件",
|
||||
"filterCli": "应用",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "集成",
|
||||
"enabledSummary": "{{count}} 个可用",
|
||||
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
|
||||
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
|
||||
"searchPlaceholder": "搜索工具",
|
||||
"featured": "工具",
|
||||
"mcpTools": "MCP 工具",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有与搜索条件匹配的工具。",
|
||||
"emptyApps": "暂无可用应用。",
|
||||
"emptyIntegrations": "暂无可用 MCP 工具。",
|
||||
"emptyIntegrations": "暂无可用集成。",
|
||||
"emptyReady": "还没有就绪的工具。",
|
||||
"clearSearch": "清除搜索",
|
||||
"browseApps": "浏览应用",
|
||||
"browseIntegrations": "浏览 MCP 工具",
|
||||
"emptyIntegrationsHint": "可在下方添加自定义 MCP 服务器。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和 MCP 工具。"
|
||||
"browseIntegrations": "浏览集成",
|
||||
"emptyIntegrationsHint": "可在下方添加自定义集成。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
|
||||
@@ -986,6 +965,10 @@
|
||||
"unarchive": "取消归档",
|
||||
"showArchived": "显示归档",
|
||||
"hideArchived": "隐藏归档",
|
||||
"select": "选择",
|
||||
"cancelSelection": "取消选择",
|
||||
"selectedCount": "已选择 {{count}} 项",
|
||||
"deleteSelected": "删除",
|
||||
"delete": "删除",
|
||||
"newChat": "新建话题",
|
||||
"groups": {
|
||||
@@ -1000,10 +983,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "删除这个话题?",
|
||||
"titleMany": "删除这 {{count}} 个话题和窗格?",
|
||||
"description": "此操作无法撤销。",
|
||||
"descriptionMany": "此操作无法撤销。",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除",
|
||||
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
|
||||
"automationsDescriptionMany": "关联的自动任务也会一并删除。",
|
||||
"moreAutomations": "另有 {{count}} 个",
|
||||
"confirmWithAutomations": "删除",
|
||||
"schedule": {
|
||||
@@ -1399,6 +1385,26 @@
|
||||
"copy": "复制",
|
||||
"copied": "已复制"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "会话工作台",
|
||||
"panes": "窗格",
|
||||
"panesInTab": "{{title}} 中的窗格",
|
||||
"dropPane": "将 {{pane}} 移入 {{tab}}",
|
||||
"moveToTab": "移动到标签页",
|
||||
"layout": "窗格布局",
|
||||
"addPane": "添加窗格",
|
||||
"promotePane": "将 {{title}} 设为主窗格",
|
||||
"paneActions": "{{title}} 窗格操作",
|
||||
"detachPane": "将 {{title}} 移至独立主题",
|
||||
"composerAria": "向 {{title}} 发送消息",
|
||||
"layouts": {
|
||||
"columns": "列布局",
|
||||
"rows": "行布局",
|
||||
"grid": "网格",
|
||||
"main-stack": "主窗格与堆栈",
|
||||
"monocle": "单窗格"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "关闭",
|
||||
"close": "关闭",
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
"filterInstalled": "已啟用",
|
||||
"filterNotInstalled": "未啟用",
|
||||
"searchPlaceholder": "搜尋 MCP 預設",
|
||||
"moreOptions": "新增 MCP 服務",
|
||||
"moreOptionsSubtitle": "連線自訂 MCP 服務,或匯入現有設定。",
|
||||
"moreOptions": "新增整合",
|
||||
"moreOptionsSubtitle": "連線自訂工具伺服器,或匯入現有設定。",
|
||||
"customTitle": "自訂 MCP",
|
||||
"customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。",
|
||||
"customAction": "自訂",
|
||||
@@ -513,14 +513,9 @@
|
||||
"serverName": "伺服器名稱",
|
||||
"serverUrl": "URL",
|
||||
"transport": "傳輸方式",
|
||||
"authentication": "驗證方式",
|
||||
"authNone": "無",
|
||||
"authHeaders": "請求標頭",
|
||||
"command": "指令",
|
||||
"args": "Args JSON",
|
||||
"headers": "請求標頭 JSON",
|
||||
"oauthAfterSave": "儲存伺服器後,選擇「連線」以登入。",
|
||||
"headersHelp": "新增此伺服器使用的請求標頭。",
|
||||
"headers": "Headers JSON",
|
||||
"env": "Env JSON",
|
||||
"timeout": "工具逾時",
|
||||
"advancedOptions": "進階選項",
|
||||
@@ -547,21 +542,6 @@
|
||||
"keepExisting": "留空以保留目前值",
|
||||
"statusConfigured": "已設定",
|
||||
"statusMissingCredentials": "需要金鑰",
|
||||
"connectingAccount": "正在連接 {{name}}",
|
||||
"connectingLabel": "正在連線…",
|
||||
"continueSignIn": "繼續登入",
|
||||
"preparingSignIn": "正在準備安全登入…",
|
||||
"openSignInToContinue": "開啟登入頁面以繼續。",
|
||||
"finishSignInInBrowser": "請在瀏覽器視窗中完成登入。",
|
||||
"manualCallbackRequired": "完成登入後,將回呼 URL 貼到 nanobot。",
|
||||
"manualCallbackHelp": "授權後,localhost 頁面將無法開啟。請複製網址列中的完整 URL 並貼到這裡。",
|
||||
"finishingConnection": "正在完成連線…",
|
||||
"activatingTools": "正在啟用工具…",
|
||||
"connected": "已連線。",
|
||||
"connectionFailed": "連線失敗。",
|
||||
"connectionCancelled": "已取消連線。",
|
||||
"reloadFailed": "已登入,但 nanobot 無法連接這些工具。請嘗試重新啟動 nanobot。",
|
||||
"oauthFailed": "無法連線,請重新登入。",
|
||||
"statusMissingDependency": "需要相依項",
|
||||
"statusComingSoon": "即將推出",
|
||||
"comingSoon": "即將推出",
|
||||
@@ -590,28 +570,27 @@
|
||||
"apps": {
|
||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||
"cliLabel": "應用程式",
|
||||
"mcpLabel": "MCP",
|
||||
"mcpLabel": "整合",
|
||||
"channelLabel": "通訊管道",
|
||||
"featureLabel": "功能",
|
||||
"filterAll": "就緒",
|
||||
"filterPlugins": "外掛程式",
|
||||
"filterCli": "應用程式",
|
||||
"filterMcp": "MCP",
|
||||
"filterMcp": "整合",
|
||||
"enabledSummary": "{{count}} 個就緒",
|
||||
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
|
||||
"caption": "{{cli}} 個應用程式 · {{mcp}} 個整合服務",
|
||||
"searchPlaceholder": "搜尋工具",
|
||||
"featured": "工具",
|
||||
"mcpTools": "MCP 工具",
|
||||
"loading": "正在載入應用程式…",
|
||||
"empty": "沒有符合搜尋條件的工具。",
|
||||
"emptyApps": "沒有可用的應用程式。",
|
||||
"emptyIntegrations": "沒有可用的 MCP 工具。",
|
||||
"emptyIntegrations": "沒有可用的整合服務。",
|
||||
"emptyReady": "尚無就緒的工具。",
|
||||
"clearSearch": "清除搜尋",
|
||||
"browseApps": "瀏覽應用程式",
|
||||
"browseIntegrations": "瀏覽 MCP 工具",
|
||||
"emptyIntegrationsHint": "可在下方新增自訂 MCP 伺服器。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與 MCP 工具。"
|
||||
"browseIntegrations": "瀏覽整合服務",
|
||||
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。",
|
||||
@@ -972,6 +951,10 @@
|
||||
"unarchive": "取消封存",
|
||||
"showArchived": "顯示封存",
|
||||
"hideArchived": "隱藏封存",
|
||||
"select": "選取",
|
||||
"cancelSelection": "取消選取",
|
||||
"selectedCount": "已選取 {{count}} 項",
|
||||
"deleteSelected": "刪除",
|
||||
"delete": "刪除",
|
||||
"newChat": "新增話題",
|
||||
"groups": {
|
||||
@@ -986,10 +969,13 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "刪除這個話題?",
|
||||
"titleMany": "刪除這 {{count}} 個話題和窗格?",
|
||||
"description": "此操作無法復原。",
|
||||
"descriptionMany": "此操作無法復原。",
|
||||
"cancel": "取消",
|
||||
"confirm": "刪除",
|
||||
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
|
||||
"automationsDescriptionMany": "關聯的自動任務也會一併刪除。",
|
||||
"moreAutomations": "另有 {{count}} 個",
|
||||
"confirmWithAutomations": "刪除",
|
||||
"schedule": {
|
||||
@@ -1385,6 +1371,26 @@
|
||||
"copy": "複製",
|
||||
"copied": "已複製"
|
||||
},
|
||||
"workbench": {
|
||||
"aria": "對話工作台",
|
||||
"panes": "窗格",
|
||||
"panesInTab": "{{title}} 中的窗格",
|
||||
"dropPane": "將 {{pane}} 移入 {{tab}}",
|
||||
"moveToTab": "移動到分頁",
|
||||
"layout": "窗格佈局",
|
||||
"addPane": "新增窗格",
|
||||
"promotePane": "將 {{title}} 設為主窗格",
|
||||
"paneActions": "{{title}} 窗格操作",
|
||||
"detachPane": "將 {{title}} 移至獨立主題",
|
||||
"composerAria": "傳送訊息給 {{title}}",
|
||||
"layouts": {
|
||||
"columns": "欄佈局",
|
||||
"rows": "列佈局",
|
||||
"grid": "網格",
|
||||
"main-stack": "主窗格與堆疊",
|
||||
"monocle": "單窗格"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"dismiss": "關閉",
|
||||
"close": "關閉",
|
||||
|
||||
+2
-65
@@ -10,7 +10,6 @@ import type {
|
||||
FilePreviewPayload,
|
||||
ImageGenerationSettingsUpdate,
|
||||
McpPresetsPayload,
|
||||
McpOAuthFlowPayload,
|
||||
MarketplaceProvider,
|
||||
NanobotFeaturesPayload,
|
||||
ModelConfigurationCreate,
|
||||
@@ -98,19 +97,7 @@ async function request<T>(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = typeof res.text === "function" ? (await res.text()).trim() : "";
|
||||
let message = text;
|
||||
if (text.startsWith("{")) {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(text);
|
||||
if (payload && typeof payload === "object") {
|
||||
const error = (payload as { error?: unknown }).error;
|
||||
if (typeof error === "string" && error.trim()) message = error.trim();
|
||||
}
|
||||
} catch {
|
||||
// Preserve non-JSON error bodies exactly as returned by the gateway.
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.status, message || `HTTP ${res.status}`);
|
||||
throw new ApiError(res.status, text || `HTTP ${res.status}`);
|
||||
}
|
||||
const contentType = res.headers?.get?.("content-type") ?? "";
|
||||
if (contentType && !contentType.toLowerCase().includes("application/json")) {
|
||||
@@ -699,56 +686,6 @@ export async function fetchMcpPresets(
|
||||
);
|
||||
}
|
||||
|
||||
export async function startMcpOAuth(
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
reset: boolean = false,
|
||||
): Promise<McpOAuthFlowPayload> {
|
||||
return mutation<McpOAuthFlowPayload>(
|
||||
transport,
|
||||
"settings.mcp.oauth_start",
|
||||
{ name, ...(reset ? { reset: true } : {}) },
|
||||
30_000,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchMcpOAuthStatus(
|
||||
token: string,
|
||||
flowId: string,
|
||||
base: string = "",
|
||||
): Promise<McpOAuthFlowPayload> {
|
||||
const query = new URLSearchParams({ flow_id: flowId });
|
||||
return request<McpOAuthFlowPayload>(
|
||||
`${base}/api/settings/mcp-oauth/status?${query}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeMcpOAuth(
|
||||
transport: WebUIMutationTransport,
|
||||
flowId: string,
|
||||
callbackUrl: string,
|
||||
): Promise<McpOAuthFlowPayload> {
|
||||
return mutation<McpOAuthFlowPayload>(
|
||||
transport,
|
||||
"settings.mcp.oauth_complete",
|
||||
{ flow_id: flowId, callback_url: callbackUrl },
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelMcpOAuth(
|
||||
transport: WebUIMutationTransport,
|
||||
flowId: string,
|
||||
): Promise<McpOAuthFlowPayload> {
|
||||
return mutation<McpOAuthFlowPayload>(
|
||||
transport,
|
||||
"settings.mcp.oauth_cancel",
|
||||
{ flow_id: flowId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProviderModels(
|
||||
token: string,
|
||||
provider: string,
|
||||
@@ -766,7 +703,7 @@ export async function fetchProviderModels(
|
||||
|
||||
export async function runMcpPresetAction(
|
||||
transport: WebUIMutationTransport,
|
||||
action: "enable" | "disable" | "remove" | "test",
|
||||
action: "enable" | "remove" | "test",
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
): Promise<McpPresetsPayload> {
|
||||
|
||||
@@ -9,9 +9,7 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
|
||||
}
|
||||
|
||||
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
|
||||
return payload.presets.filter(
|
||||
(preset) => preset.enabled ?? (preset.installed && preset.configured),
|
||||
);
|
||||
return payload.presets.filter((preset) => preset.installed && preset.configured);
|
||||
}
|
||||
|
||||
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
||||
export const PANE_DRAG_TYPE = "application/x-nanobot-pane";
|
||||
|
||||
export interface DraggedPane {
|
||||
paneKey: string;
|
||||
sourceTabKey: string;
|
||||
}
|
||||
|
||||
let activeSessionKey: string | null = null;
|
||||
let activePane: DraggedPane | null = null;
|
||||
|
||||
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
||||
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
||||
@@ -13,6 +20,7 @@ export function readDraggedSession(dataTransfer: DataTransfer): string | null {
|
||||
|
||||
export function clearDraggedSession(): void {
|
||||
activeSessionKey = null;
|
||||
activePane = null;
|
||||
}
|
||||
|
||||
export function writeDraggedSession(
|
||||
@@ -23,3 +31,27 @@ export function writeDraggedSession(
|
||||
dataTransfer.effectAllowed = "copyMove";
|
||||
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
||||
}
|
||||
|
||||
export function readDraggedPane(dataTransfer: DataTransfer): DraggedPane | null {
|
||||
const serialized = dataTransfer.getData(PANE_DRAG_TYPE).trim();
|
||||
if (serialized) {
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as Partial<DraggedPane>;
|
||||
if (parsed.paneKey && parsed.sourceTabKey) {
|
||||
return { paneKey: parsed.paneKey, sourceTabKey: parsed.sourceTabKey };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the in-memory payload used while the native drag is active.
|
||||
}
|
||||
}
|
||||
return activePane;
|
||||
}
|
||||
|
||||
export function writeDraggedPane(
|
||||
dataTransfer: DataTransfer,
|
||||
pane: DraggedPane,
|
||||
): void {
|
||||
activePane = pane;
|
||||
writeDraggedSession(dataTransfer, pane.paneKey);
|
||||
dataTransfer.setData(PANE_DRAG_TYPE, JSON.stringify(pane));
|
||||
}
|
||||
|
||||
@@ -956,13 +956,11 @@ export interface McpPresetInfo {
|
||||
description: string;
|
||||
docs_url: string;
|
||||
transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string;
|
||||
auth?: "oauth" | null;
|
||||
requires: string;
|
||||
note: string;
|
||||
install_supported: boolean;
|
||||
installed: boolean;
|
||||
configured: boolean;
|
||||
enabled?: boolean;
|
||||
available: boolean;
|
||||
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
||||
logo_url?: string | null;
|
||||
@@ -978,30 +976,6 @@ export interface McpPresetInfo {
|
||||
manifest?: AppManifest;
|
||||
}
|
||||
|
||||
export type McpOAuthFlowStatus =
|
||||
| "starting"
|
||||
| "authorization_required"
|
||||
| "connecting"
|
||||
| "authorized"
|
||||
| "connected"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface McpOAuthFlowPayload {
|
||||
flow_id: string;
|
||||
name: string;
|
||||
status: McpOAuthFlowStatus;
|
||||
expires_in: number;
|
||||
authorization_url?: string;
|
||||
completion_input?: "callback_url";
|
||||
error?: string;
|
||||
hot_reload?: {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
requires_restart?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpPresetsPayload {
|
||||
presets: McpPresetInfo[];
|
||||
installed_count: number;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
cancelMcpOAuth,
|
||||
configureChannel,
|
||||
completeMcpOAuth,
|
||||
completeProviderOAuth,
|
||||
createModelConfiguration,
|
||||
createProviderSettings,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
fetchApiService,
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpOAuthStatus,
|
||||
fetchMcpPresets,
|
||||
fetchMarketplaceSkillTrends,
|
||||
fetchNanobotFeatures,
|
||||
@@ -44,7 +41,6 @@ import {
|
||||
saveCustomMcpServer,
|
||||
searchMarketplaceSkills,
|
||||
startApiService,
|
||||
startMcpOAuth,
|
||||
stopApiService,
|
||||
cancelChannelConnect,
|
||||
pollChannelConnect,
|
||||
@@ -549,23 +545,6 @@ describe("webui API helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts user-facing messages from JSON API errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
text: async () => JSON.stringify({ error: "Paste the complete callback URL." }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchMcpOAuthStatus("tok", "flow-123")).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: "Paste the complete callback URL.",
|
||||
});
|
||||
});
|
||||
|
||||
it("times out when an API request never responds", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||
@@ -903,42 +882,6 @@ describe("webui API helpers", () => {
|
||||
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await runMcpPresetAction(mutationTransport, "disable", "plugin-desktop");
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.mcp.disable",
|
||||
{ name: "plugin-desktop" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await startMcpOAuth(mutationTransport, "notion", true);
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.mcp.oauth_start",
|
||||
{ name: "notion", reset: true },
|
||||
30_000,
|
||||
);
|
||||
|
||||
await fetchMcpOAuthStatus("tok", "flow-123");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-oauth/status?flow_id=flow-123",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
const callbackUrl =
|
||||
"http://127.0.0.1:8765/auth/mcp/callback?code=secret&state=state-123";
|
||||
await completeMcpOAuth(mutationTransport, "flow-123", callbackUrl);
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.mcp.oauth_complete",
|
||||
{ flow_id: "flow-123", callback_url: callbackUrl },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await cancelMcpOAuth(mutationTransport, "flow-123");
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.mcp.oauth_cancel",
|
||||
{ flow_id: "flow-123" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
||||
@@ -956,19 +899,6 @@ describe("webui API helpers", () => {
|
||||
20_000,
|
||||
);
|
||||
|
||||
const oauthCustom = {
|
||||
name: "company-mcp",
|
||||
transport: "streamableHttp",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
auth: "oauth",
|
||||
};
|
||||
await saveCustomMcpServer(mutationTransport, oauthCustom);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.mcp.custom",
|
||||
oauthCustom,
|
||||
20_000,
|
||||
);
|
||||
|
||||
await importMcpConfig(
|
||||
mutationTransport,
|
||||
'{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||
|
||||
@@ -164,7 +164,24 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: refreshSpy,
|
||||
createChat: createChatSpy,
|
||||
createChat: async (scope?: WorkspaceScopePayload | null) => {
|
||||
const chatId = await createChatSpy(scope);
|
||||
const now = new Date().toISOString();
|
||||
setSessions((prev: ChatSummary[]) => [
|
||||
{
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
title: "",
|
||||
preview: "",
|
||||
workspaceScope: scope ?? null,
|
||||
},
|
||||
...prev.filter((session) => session.chatId !== chatId),
|
||||
]);
|
||||
return chatId;
|
||||
},
|
||||
forkChat: async () => "fork-chat",
|
||||
getSessionAutomations: getSessionAutomationsSpy,
|
||||
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
@@ -290,6 +307,8 @@ describe("App layout", () => {
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
||||
localStorage.removeItem("nanobot-webui.restartRoute");
|
||||
localStorage.removeItem("nanobot.webui.workbench.v1");
|
||||
localStorage.removeItem("nanobot.webui.workbench.v2");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
api_token: "api-tok",
|
||||
@@ -485,8 +504,9 @@ describe("App layout", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const firstMessage = "keep this first turn visible";
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
target: { value: "/model" },
|
||||
target: { value: firstMessage },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
@@ -496,6 +516,7 @@ describe("App layout", () => {
|
||||
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText(firstMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a new temporary chat from the hero each time", async () => {
|
||||
@@ -1649,6 +1670,60 @@ describe("App layout", () => {
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
}, 15_000);
|
||||
|
||||
it("deletes multiple selected topics through one confirmation", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "First chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
preview: "Second chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-c",
|
||||
channel: "websocket",
|
||||
chatId: "chat-c",
|
||||
createdAt: "2026-04-16T12:00:00Z",
|
||||
updatedAt: "2026-04-16T12:00:00Z",
|
||||
preview: "Third chat",
|
||||
},
|
||||
];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.pointerDown(within(sidebar).getByLabelText(
|
||||
"Topic actions for First chat",
|
||||
), { button: 0 });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Second chat" }));
|
||||
expect(within(sidebar).getByText("2 selected")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Delete" }));
|
||||
expect(await screen.findByText("Delete 2 topics and panes?")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledTimes(2));
|
||||
expect(deleteChatSpy.mock.calls.map(([key]) => key)).toEqual([
|
||||
"websocket:chat-a",
|
||||
"websocket:chat-b",
|
||||
]);
|
||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
|
||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-b");
|
||||
expect(within(sidebar).getByRole("button", { name: "Third chat" }))
|
||||
.toBeInTheDocument();
|
||||
}, 15_000);
|
||||
|
||||
it("shows localized bound automations in the first delete confirmation", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
@@ -2943,6 +3018,109 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps panes and layout scoped to the current topic tab", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
createChatSpy.mockResolvedValueOnce("chat-pane");
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-alpha",
|
||||
channel: "websocket",
|
||||
chatId: "chat-alpha",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
title: "Alpha",
|
||||
preview: "Alpha notes",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-beta",
|
||||
channel: "websocket",
|
||||
chatId: "chat-beta",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
title: "Beta",
|
||||
preview: "Beta notes",
|
||||
},
|
||||
];
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/#/chat/websocket%3Achat-alpha",
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const grid = await screen.findByTestId("pane-grid");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add pane" }));
|
||||
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
|
||||
await waitFor(() => expect(grid.children).toHaveLength(2));
|
||||
expect(window.location.hash).toBe("#/chat/websocket%3Achat-alpha");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "New topic"]);
|
||||
|
||||
const activeComposer = screen.getByTestId("active-pane-composer");
|
||||
const paneInput = within(activeComposer).getByRole("textbox", {
|
||||
name: "Message New topic",
|
||||
});
|
||||
fireEvent.change(paneInput, { target: { value: "route this to the new pane" } });
|
||||
fireEvent.keyDown(paneInput, { key: "Enter" });
|
||||
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
|
||||
expect(sendMessageSpy.mock.calls.at(-1)?.[0]).toBe("chat-pane");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||
expect(grid).toHaveAttribute("data-layout", "rows");
|
||||
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const paneTopicButton = within(sidebar)
|
||||
.getAllByRole("button", { name: "New topic" })
|
||||
.find((button) => button.closest("[data-sidebar-pane]"));
|
||||
expect(paneTopicButton).toBeDefined();
|
||||
expect(paneTopicButton?.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:chat-pane");
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Beta" }));
|
||||
await waitFor(() => {
|
||||
const nextGrid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(nextGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta"]);
|
||||
expect(nextGrid).toHaveAttribute("data-layout", "columns");
|
||||
});
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Alpha" }));
|
||||
await waitFor(() => {
|
||||
const restoredGrid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(restoredGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "New topic"]);
|
||||
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
||||
name: "New topic pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: "Move New topic to its own topic",
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
||||
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("opens search from the keyboard shortcut", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { createEvent, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import { PANE_DRAG_TYPE, SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
@@ -42,6 +42,26 @@ function rect({
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function dragOverAt(
|
||||
element: Element,
|
||||
clientY: number,
|
||||
dataTransfer: Record<string, unknown>,
|
||||
): void {
|
||||
const event = createEvent.dragOver(element, { dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: clientY });
|
||||
fireEvent(element, event);
|
||||
}
|
||||
|
||||
function dropAt(
|
||||
element: Element,
|
||||
clientY: number,
|
||||
dataTransfer: Record<string, unknown>,
|
||||
): void {
|
||||
const event = createEvent.drop(element, { dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: clientY });
|
||||
fireEvent(element, event);
|
||||
}
|
||||
|
||||
describe("ChatList", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -82,7 +102,13 @@ describe("ChatList", () => {
|
||||
fireEvent.dragEnd(reference, { dataTransfer });
|
||||
});
|
||||
|
||||
it("reorders chats around a Codex-style insertion line", () => {
|
||||
it("reorders topics with a displaced-neighbor preview instead of an insertion line", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 284,
|
||||
height: 32,
|
||||
}));
|
||||
const onReorderSessions = vi.fn();
|
||||
const sessions = [
|
||||
session({ chatId: "alpha", title: "Alpha" }),
|
||||
@@ -112,10 +138,14 @@ describe("ChatList", () => {
|
||||
};
|
||||
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
|
||||
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
|
||||
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
|
||||
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
|
||||
dragOverAt(charlieRow, 24, dataTransfer);
|
||||
expect(document.querySelector("[data-session-drop-edge]")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Bravo" }).closest("li"))
|
||||
.toHaveAttribute("data-session-displaced", "true");
|
||||
expect(charlieRow).toHaveStyle({ transform: "translateY(-32px)" });
|
||||
expect(screen.getByRole("button", { name: "Alpha" }).closest("li"))
|
||||
.toHaveAttribute("data-session-dragging", "true");
|
||||
dropAt(charlieRow, 24, dataTransfer);
|
||||
|
||||
expect(onReorderSessions).toHaveBeenCalledWith([
|
||||
"websocket:bravo",
|
||||
@@ -152,6 +182,228 @@ describe("ChatList", () => {
|
||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
||||
});
|
||||
|
||||
it("shows every tab's pane membership in the sidebar tree", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onSelectPane = vi.fn();
|
||||
const onDetachPane = vi.fn();
|
||||
const onPromotePane = vi.fn();
|
||||
const onRequestRename = vi.fn();
|
||||
const onAttachPane = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "root", title: "Root topic" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
topicKey: "websocket:root",
|
||||
activePaneKey: "websocket:child",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
"websocket:target": {
|
||||
topicKey: "websocket:target",
|
||||
activePaneKey: "websocket:target-child",
|
||||
panes: [
|
||||
{ key: "websocket:target", chatId: "target", title: "Target tab" },
|
||||
{
|
||||
key: "websocket:target-child",
|
||||
chatId: "target-child",
|
||||
title: "Target research",
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
onSelectPane={onSelectPane}
|
||||
onDetachPane={onDetachPane}
|
||||
onPromotePane={onPromotePane}
|
||||
paneAcceptingTabKeys={["websocket:target"]}
|
||||
onAttachPane={onAttachPane}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={onRequestRename}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const child = screen.getByRole("button", { name: "Research pane" });
|
||||
expect(child.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:child");
|
||||
expect(child).toHaveAttribute("aria-current", "true");
|
||||
const targetTabRow = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
|
||||
const targetChild = within(targetTabRow).getByRole("button", {
|
||||
name: "Target research",
|
||||
});
|
||||
expect(targetChild.closest("[data-sidebar-pane]"))
|
||||
.toHaveAttribute("data-sidebar-pane", "websocket:target-child");
|
||||
expect(targetChild).not.toHaveAttribute("aria-current");
|
||||
fireEvent.click(targetChild);
|
||||
expect(onSelectPane).toHaveBeenCalledWith(
|
||||
"websocket:target",
|
||||
"websocket:target-child",
|
||||
);
|
||||
fireEvent.click(child);
|
||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Root topic" }));
|
||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root");
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Research pane pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
const moveToTab = await screen.findByRole("menuitem", { name: "Move to tab" });
|
||||
fireEvent.pointerMove(moveToTab, { pointerType: "mouse" });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab" }));
|
||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Research pane pane actions",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", {
|
||||
name: "Move Research pane to its own topic",
|
||||
}));
|
||||
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
||||
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
fireEvent.dragStart(child, { dataTransfer });
|
||||
expect(child.closest("li")).toHaveAttribute("data-pane-dragging", "true");
|
||||
expect(child.closest("li")).not.toHaveClass("opacity-0");
|
||||
const targetTab = screen.getByRole("button", { name: "Target tab" });
|
||||
dragOverAt(targetTab.closest("li")!, 0, dataTransfer);
|
||||
expect(targetTab.closest("li"))
|
||||
.toHaveAttribute("data-tab-attach-target", "true");
|
||||
expect(within(targetTab.closest("li")!).getByRole("status", {
|
||||
name: "Move Research pane into Target tab",
|
||||
})).toHaveTextContent("Research pane");
|
||||
dropAt(targetTab.closest("li")!, 0, dataTransfer);
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith(
|
||||
PANE_DRAG_TYPE,
|
||||
JSON.stringify({
|
||||
paneKey: "websocket:child",
|
||||
sourceTabKey: "websocket:root",
|
||||
}),
|
||||
);
|
||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
||||
});
|
||||
|
||||
it("selects a whole tab or individual panes for one bulk delete", async () => {
|
||||
const onRequestDeleteMany = vi.fn();
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "root", title: "Root topic" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"websocket:root": {
|
||||
topicKey: "websocket:root",
|
||||
activePaneKey: "websocket:root",
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onRequestDeleteMany={onRequestDeleteMany}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", {
|
||||
name: "Topic actions for Root topic",
|
||||
}), { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Root topic" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Research pane" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Target tab" }));
|
||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", {
|
||||
name: "Delete",
|
||||
}));
|
||||
|
||||
expect(onRequestDeleteMany).toHaveBeenCalledWith([
|
||||
{ key: "websocket:root", label: "Root topic" },
|
||||
{ key: "websocket:child", label: "Research pane" },
|
||||
{ key: "websocket:target", label: "Target tab" },
|
||||
]);
|
||||
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reattaches a one-pane tab through the center of another tab", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 284,
|
||||
height: 32,
|
||||
}));
|
||||
const onAttachPane = vi.fn();
|
||||
const onReorderSessions = vi.fn();
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "detached", title: "Detached pane" }),
|
||||
session({ chatId: "target", title: "Target tab" }),
|
||||
]}
|
||||
activeKey={null}
|
||||
attachableTabKeys={["websocket:detached", "websocket:target"]}
|
||||
paneAcceptingTabKeys={["websocket:detached", "websocket:target"]}
|
||||
onAttachPane={onAttachPane}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
onReorderSessions={onReorderSessions}
|
||||
/>,
|
||||
);
|
||||
|
||||
const detached = screen.getByRole("button", { name: "Detached pane" });
|
||||
fireEvent.dragStart(detached, {
|
||||
dataTransfer,
|
||||
});
|
||||
expect(detached.closest("li"))
|
||||
.toHaveAttribute("data-session-dragging", "true");
|
||||
const target = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
|
||||
dragOverAt(target, 16, dataTransfer);
|
||||
expect(target).toHaveAttribute("data-tab-attach-target", "true");
|
||||
expect(document.querySelector("[data-session-displaced='true']"))
|
||||
.not.toBeInTheDocument();
|
||||
dropAt(target, 16, dataTransfer);
|
||||
|
||||
expect(onAttachPane).toHaveBeenCalledWith(
|
||||
"websocket:detached",
|
||||
"websocket:target",
|
||||
);
|
||||
expect(onReorderSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
||||
const temporarySession = session({
|
||||
key: "temporary:temporary-one",
|
||||
|
||||
@@ -75,18 +75,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.apps.description",
|
||||
"settings.apps.caption",
|
||||
"settings.apps.restartRequired",
|
||||
"settings.mcp.connectingAccount",
|
||||
"settings.mcp.continueSignIn",
|
||||
"settings.mcp.preparingSignIn",
|
||||
"settings.mcp.openSignInToContinue",
|
||||
"settings.mcp.finishSignInInBrowser",
|
||||
"settings.mcp.finishingConnection",
|
||||
"settings.mcp.activatingTools",
|
||||
"settings.mcp.connected",
|
||||
"settings.mcp.connectionFailed",
|
||||
"settings.mcp.connectionCancelled",
|
||||
"settings.mcp.reloadFailed",
|
||||
"settings.mcp.oauthFailed",
|
||||
"settings.skills.views",
|
||||
"settings.skills.installedTab",
|
||||
"settings.skills.discoverTab",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||
import {
|
||||
EMPTY_WORKBENCH_STATE,
|
||||
addWorkbenchPane,
|
||||
focusWorkbenchPane,
|
||||
setWorkbenchLayout,
|
||||
workbenchTab,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function WorkbenchHarness() {
|
||||
const [state, setState] = useState(() => (
|
||||
addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta")
|
||||
));
|
||||
const tab = workbenchTab(state, "alpha");
|
||||
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
|
||||
|
||||
return (
|
||||
<PaneWorkbench
|
||||
panes={tab.paneKeys.map((key) => ({ key, title: titles[key] }))}
|
||||
activePaneKey={tab.activePaneKey}
|
||||
layout={tab.layout}
|
||||
onActivatePane={(key) => setState((current) => (
|
||||
focusWorkbenchPane(current, "alpha", key)
|
||||
))}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={(layout) => setState((current) => (
|
||||
setWorkbenchLayout(current, "alpha", layout)
|
||||
))}
|
||||
renderPane={(pane, context) => (
|
||||
<>
|
||||
<button type="button">Focus {pane.title}</button>
|
||||
{context.headerPortalTarget && context.active ? createPortal(
|
||||
context.headerActions,
|
||||
context.headerPortalTarget,
|
||||
) : null}
|
||||
{context.composerPortalTarget ? createPortal(
|
||||
<div hidden={!context.active}>
|
||||
<textarea aria-label={`Composer ${pane.title}`} />
|
||||
</div>,
|
||||
context.composerPortalTarget,
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PaneWorkbench", () => {
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalAnimate = HTMLElement.prototype.animate;
|
||||
const animate = vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}) as unknown as Animation);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
HTMLElement.prototype.animate = animate;
|
||||
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
if (!this.classList.contains("workbench-pane")) {
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
}
|
||||
const layout = this.parentElement?.dataset.layout;
|
||||
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
|
||||
return layout === "rows"
|
||||
? rect(0, index * 500, 1000, 500)
|
||||
: rect(index * 500, 0, 500, 1000);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.animate = originalAnimate;
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("focuses without reordering and keeps only the focused composer visible", () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
|
||||
|
||||
fireEvent.pointerDown(
|
||||
within(screen.getByRole("region", { name: "Alpha" }))
|
||||
.getByRole("button", { name: "Focus Alpha" }),
|
||||
);
|
||||
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps one shared layout control and animates geometry changes", async () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const header = screen.getByTestId("workbench-header-host");
|
||||
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
|
||||
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
|
||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { installedMcpPresetsFromPayload } from "@/lib/mcp-preset-events";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
ChannelSetupContract,
|
||||
@@ -335,29 +334,6 @@ const installedAnyGen = {
|
||||
skill_installed: true,
|
||||
};
|
||||
|
||||
const xmindMcpPreset = {
|
||||
name: "xmind",
|
||||
display_name: "Xmind",
|
||||
category: "productivity",
|
||||
description: "Create, read, and edit cloud mind maps through Xmind.",
|
||||
docs_url: "https://xmind.com/user-guide/xmind-mcp",
|
||||
transport: "streamableHttp",
|
||||
auth: "oauth" as const,
|
||||
requires: "Xmind account",
|
||||
note: "Connects securely in your browser with Xmind OAuth.",
|
||||
install_supported: true,
|
||||
installed: false,
|
||||
configured: false,
|
||||
available: false,
|
||||
status: "not_installed",
|
||||
logo_url: null,
|
||||
brand_color: "#F4B41A",
|
||||
required_fields: [],
|
||||
connection_summary: "",
|
||||
enabled_tools: ["*"],
|
||||
source: "preset",
|
||||
};
|
||||
|
||||
function renderSettingsView(
|
||||
options: {
|
||||
initialSection?:
|
||||
@@ -676,612 +652,6 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables and disables an installed Agent Plugin explicitly", async () => {
|
||||
const plugin = {
|
||||
name: "plugin-computer-use",
|
||||
display_name: "Computer Use",
|
||||
description: "Control the desktop with a live preview.",
|
||||
category: "Productivity",
|
||||
docs_url: "https://github.com/nanobot-dev/computer-use",
|
||||
transport: "stdio",
|
||||
requires: "screen-recording, accessibility",
|
||||
note: "",
|
||||
install_supported: false,
|
||||
installed: true,
|
||||
configured: true,
|
||||
enabled: false,
|
||||
available: false,
|
||||
status: "disabled",
|
||||
logo_url: null,
|
||||
brand_color: "#ff7a1a",
|
||||
required_fields: [],
|
||||
connection_summary: "computer-use",
|
||||
source: "agent-plugin",
|
||||
};
|
||||
expect(installedMcpPresetsFromPayload({ presets: [plugin], installed_count: 0 })).toEqual([]);
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [plugin], installed_count: 0 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock
|
||||
.mockResolvedValueOnce({
|
||||
presets: [{ ...plugin, enabled: true, available: true, status: "enabled" }],
|
||||
installed_count: 1,
|
||||
last_action: { ok: true, message: "Computer Use enabled." },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
presets: [{ ...plugin, enabled: false, available: false, status: "disabled" }],
|
||||
installed_count: 0,
|
||||
last_action: { ok: true, message: "Computer Use disabled." },
|
||||
});
|
||||
|
||||
renderSettingsView();
|
||||
|
||||
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plugins")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.mcp.enable",
|
||||
{ name: "plugin-computer-use" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
const enabledButton = await screen.findByRole("button", { name: "Computer Use: Enabled" });
|
||||
await waitFor(() => expect(enabledButton).toBeEnabled());
|
||||
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.mcp.disable",
|
||||
{ name: "plugin-computer-use" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Enable" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => {
|
||||
let connected = false;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({
|
||||
presets: [connected
|
||||
? {
|
||||
...xmindMcpPreset,
|
||||
installed: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
status: "configured",
|
||||
connection_summary: "https://app.xmind.com/api/mcp",
|
||||
}
|
||||
: xmindMcpPreset],
|
||||
installed_count: connected ? 1 : 0,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") {
|
||||
connected = true;
|
||||
return jsonResponse({
|
||||
flow_id: "flow-123",
|
||||
name: "xmind",
|
||||
status: "connected",
|
||||
expires_in: 295,
|
||||
hot_reload: {
|
||||
ok: false,
|
||||
requires_restart: false,
|
||||
connected: ["xmind"],
|
||||
failed: ["notion"],
|
||||
message: "MCP config reloaded, but some servers did not connect: notion",
|
||||
},
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock.mockImplementation(async (action: string) => {
|
||||
if (action === "settings.mcp.oauth_start") {
|
||||
return {
|
||||
flow_id: "flow-123",
|
||||
name: "xmind",
|
||||
status: "authorization_required",
|
||||
expires_in: 300,
|
||||
authorization_url: "https://accounts.xmind.test/authorize?state=state-123",
|
||||
};
|
||||
}
|
||||
return settingsPayload();
|
||||
});
|
||||
const replace = vi.fn();
|
||||
const popup = {
|
||||
opener: window,
|
||||
closed: false,
|
||||
location: { replace },
|
||||
document: { title: "", body: { textContent: "" } },
|
||||
focus: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const open = vi.fn(() => popup);
|
||||
vi.stubGlobal("open", open);
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
expect(screen.getByText("MCP tools")).toBeInTheDocument();
|
||||
const connectButton = await screen.findByRole("button", { name: "Connect Xmind" });
|
||||
expect(connectButton).toHaveTextContent("Connect");
|
||||
fireEvent.click(connectButton);
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"about:blank",
|
||||
"nanobot-mcp-oauth",
|
||||
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
|
||||
);
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith(
|
||||
"https://accounts.xmind.test/authorize?state=state-123",
|
||||
));
|
||||
expect(popup.opener).toBeNull();
|
||||
expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Finish signing in in the browser window.",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent(
|
||||
"Connecting…",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
|
||||
.toHaveTextContent("Configured");
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
|
||||
expect(popup.close).toHaveBeenCalledTimes(1);
|
||||
expect(replace).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-oauth/status?flow_id=flow-123",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("configures OAuth for a custom remote MCP without importing JSON", async () => {
|
||||
const customPreset = {
|
||||
...xmindMcpPreset,
|
||||
name: "team-mcp",
|
||||
display_name: "team-mcp",
|
||||
source: "custom",
|
||||
installed: true,
|
||||
status: "authorization_required",
|
||||
connection_summary: "https://mcp.example.com/mcp",
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock.mockImplementation(async (action: string) => {
|
||||
if (action === "settings.mcp.custom") {
|
||||
return {
|
||||
presets: [customPreset],
|
||||
installed_count: 1,
|
||||
hot_reload: {
|
||||
ok: false,
|
||||
message: "MCP config reloaded, but some servers did not connect: team-mcp",
|
||||
failed: ["team-mcp"],
|
||||
},
|
||||
last_action: { ok: true, message: "Saved custom MCP server team-mcp." },
|
||||
};
|
||||
}
|
||||
return settingsPayload();
|
||||
});
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
|
||||
|
||||
expect(screen.queryByText("Authentication")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Server name"), {
|
||||
target: { value: "team-mcp" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "HTTP" }));
|
||||
fireEvent.change(screen.getByLabelText("URL"), {
|
||||
target: { value: "https://mcp.example.com/mcp" },
|
||||
});
|
||||
|
||||
const authentication = screen.getByRole("group", { name: "Authentication" });
|
||||
const oauth = within(authentication).getByRole("button", { name: "OAuth" });
|
||||
expect(oauth).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(within(authentication).getByRole("button", { name: "Headers" }));
|
||||
fireEvent.change(screen.getByLabelText("Headers JSON"), {
|
||||
target: { value: '{"Authorization":"Bearer stale"}' },
|
||||
});
|
||||
expect(screen.getByText("Add the request headers used by this server.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(oauth);
|
||||
expect(oauth).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByLabelText("Headers JSON")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Save the server, then select Connect to sign in."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save MCP" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const saveCall = requestMutationMock.mock.calls.find(
|
||||
([action]) => action === "settings.mcp.custom",
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
const values = saveCall?.[1] as Record<string, string>;
|
||||
expect(values).toMatchObject({
|
||||
name: "team-mcp",
|
||||
transport: "streamableHttp",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
auth: "oauth",
|
||||
});
|
||||
expect(values).not.toHaveProperty("headers");
|
||||
expect(saveCall?.[2]).toBe(20_000);
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Connect team-mcp" }))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("MCP config reloaded, but some servers did not connect: team-mcp"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a pasted callback flow when the remote WebUI uses HTTP", async () => {
|
||||
let completed = false;
|
||||
const callbackUrl =
|
||||
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=manual-state";
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({
|
||||
presets: [completed
|
||||
? {
|
||||
...xmindMcpPreset,
|
||||
installed: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
status: "configured",
|
||||
connection_summary: "https://app.xmind.com/api/mcp",
|
||||
}
|
||||
: xmindMcpPreset],
|
||||
installed_count: completed ? 1 : 0,
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-manual") {
|
||||
return jsonResponse({
|
||||
flow_id: "flow-manual",
|
||||
name: "xmind",
|
||||
status: completed ? "connected" : "authorization_required",
|
||||
expires_in: 298,
|
||||
completion_input: "callback_url",
|
||||
authorization_url: completed
|
||||
? undefined
|
||||
: "https://accounts.xmind.test/authorize?state=manual-state",
|
||||
hot_reload: completed ? { ok: true, requires_restart: false } : undefined,
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock.mockImplementation(async (action: string) => {
|
||||
if (action === "settings.mcp.oauth_start") {
|
||||
return {
|
||||
flow_id: "flow-manual",
|
||||
name: "xmind",
|
||||
status: "authorization_required",
|
||||
expires_in: 300,
|
||||
completion_input: "callback_url",
|
||||
authorization_url: "https://accounts.xmind.test/authorize?state=manual-state",
|
||||
};
|
||||
}
|
||||
if (action === "settings.mcp.oauth_complete") {
|
||||
completed = true;
|
||||
return {
|
||||
flow_id: "flow-manual",
|
||||
name: "xmind",
|
||||
status: "connecting",
|
||||
expires_in: 299,
|
||||
completion_input: "callback_url",
|
||||
};
|
||||
}
|
||||
return settingsPayload();
|
||||
});
|
||||
const popup = {
|
||||
opener: window,
|
||||
closed: false,
|
||||
location: { replace: vi.fn() },
|
||||
document: { title: "", body: { textContent: "" } },
|
||||
focus: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal("open", vi.fn(() => popup));
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||
|
||||
const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" });
|
||||
expect(screen.getByText(/localhost page will not load/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Finish signing in, then paste the callback URL into nanobot.",
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
|
||||
|
||||
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.mcp.oauth_complete",
|
||||
{ flow_id: "flow-manual", callback_url: callbackUrl },
|
||||
20_000,
|
||||
));
|
||||
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
|
||||
.toHaveTextContent("Configured");
|
||||
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
|
||||
expect(popup.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the user cancel an active OAuth connection after closing the popup", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-cancel") {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock.mockImplementation(async (action: string) => {
|
||||
if (action === "settings.mcp.oauth_start") {
|
||||
return {
|
||||
flow_id: "flow-cancel",
|
||||
name: "xmind",
|
||||
status: "authorization_required",
|
||||
expires_in: 300,
|
||||
authorization_url: "https://accounts.xmind.test/authorize?state=cancel",
|
||||
};
|
||||
}
|
||||
if (action === "settings.mcp.oauth_cancel") {
|
||||
return {
|
||||
flow_id: "flow-cancel",
|
||||
name: "xmind",
|
||||
status: "cancelled",
|
||||
expires_in: 299,
|
||||
};
|
||||
}
|
||||
return settingsPayload();
|
||||
});
|
||||
const popup = {
|
||||
opener: window,
|
||||
closed: false,
|
||||
location: { replace: vi.fn() },
|
||||
document: { title: "", body: { textContent: "" } },
|
||||
focus: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal("open", vi.fn(() => popup));
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||
|
||||
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
|
||||
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toBeInTheDocument();
|
||||
popup.closed = true;
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.mcp.oauth_cancel",
|
||||
{ flow_id: "flow-cancel" },
|
||||
20_000,
|
||||
));
|
||||
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Connecting Xmind" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||
expect(popup.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silently removes an MCP when the card already shows the result", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({
|
||||
presets: [{
|
||||
...xmindMcpPreset,
|
||||
installed: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
status: "configured",
|
||||
connection_summary: "https://app.xmind.com/api/mcp",
|
||||
}],
|
||||
installed_count: 1,
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock.mockResolvedValueOnce({
|
||||
presets: [xmindMcpPreset],
|
||||
installed_count: 0,
|
||||
requires_restart: false,
|
||||
hot_reload: {
|
||||
ok: true,
|
||||
message: "MCP config reloaded without restarting nanobot.",
|
||||
},
|
||||
last_action: {
|
||||
ok: true,
|
||||
message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.",
|
||||
removed: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
|
||||
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.mcp.remove",
|
||||
{ name: "xmind" },
|
||||
20_000,
|
||||
));
|
||||
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a one-click recovery when the OAuth popup is blocked", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
||||
}
|
||||
return new Promise<Response>(() => {});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock.mockImplementation(async (action: string) => {
|
||||
if (action === "settings.mcp.oauth_start") {
|
||||
return {
|
||||
flow_id: "flow-blocked",
|
||||
name: "xmind",
|
||||
status: "authorization_required",
|
||||
expires_in: 300,
|
||||
authorization_url: "https://accounts.xmind.test/authorize?state=blocked",
|
||||
};
|
||||
}
|
||||
return settingsPayload();
|
||||
});
|
||||
const popup = {
|
||||
opener: window,
|
||||
closed: false,
|
||||
location: { replace: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const open = vi.fn()
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(popup);
|
||||
vi.stubGlobal("open", open);
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||
|
||||
const continueButton = await screen.findByRole("button", { name: "Continue sign-in" });
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue.");
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
fireEvent.click(continueButton);
|
||||
expect(open).toHaveBeenLastCalledWith(
|
||||
"https://accounts.xmind.test/authorize?state=blocked",
|
||||
"nanobot-mcp-oauth",
|
||||
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => {
|
||||
let popupIsolated = false;
|
||||
let statusCalls = 0;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") {
|
||||
statusCalls += 1;
|
||||
return jsonResponse({
|
||||
flow_id: "flow-coop",
|
||||
name: "xmind",
|
||||
status: statusCalls === 1 ? "authorization_required" : "failed",
|
||||
expires_in: 299,
|
||||
error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.",
|
||||
authorization_url: statusCalls === 1
|
||||
? "https://accounts.xmind.test/authorize?state=coop"
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
requestMutationMock.mockImplementation(async (action: string) => {
|
||||
if (action === "settings.mcp.oauth_start") {
|
||||
return {
|
||||
flow_id: "flow-coop",
|
||||
name: "xmind",
|
||||
status: "authorization_required",
|
||||
expires_in: 300,
|
||||
authorization_url: "https://accounts.xmind.test/authorize?state=coop",
|
||||
};
|
||||
}
|
||||
return settingsPayload();
|
||||
});
|
||||
const popup = {
|
||||
opener: window,
|
||||
get closed() {
|
||||
return popupIsolated;
|
||||
},
|
||||
location: {
|
||||
replace: vi.fn(() => {
|
||||
popupIsolated = true;
|
||||
}),
|
||||
},
|
||||
document: { title: "", body: { textContent: "" } },
|
||||
focus: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal("open", vi.fn(() => popup));
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||
|
||||
await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 });
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Finish signing in in the browser window.",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
@@ -1324,7 +694,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Api")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
|
||||
@@ -2653,8 +2023,8 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" }));
|
||||
expect(await screen.findByText("Add MCP server")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse integrations" }));
|
||||
expect(await screen.findByText("Add integration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows token activity on the overview", async () => {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
EMPTY_WORKBENCH_STATE,
|
||||
MAX_WORKBENCH_PANES,
|
||||
addWorkbenchPane,
|
||||
attachWorkbenchPane,
|
||||
detachWorkbenchPane,
|
||||
ensureWorkbenchTab,
|
||||
focusWorkbenchPane,
|
||||
parseWorkbenchState,
|
||||
promoteWorkbenchPane,
|
||||
reconcileWorkbench,
|
||||
setWorkbenchLayout,
|
||||
workbenchChildPaneKeys,
|
||||
workbenchTab,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
|
||||
describe("workbench model", () => {
|
||||
it("gives every topic its own one-pane tab by default", () => {
|
||||
const state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toEqual({
|
||||
paneKeys: ["topic-a"],
|
||||
activePaneKey: "topic-a",
|
||||
layout: "columns",
|
||||
});
|
||||
expect(state.tabs["topic-b"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps pane membership, focus, and layout scoped to a tab", () => {
|
||||
let state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
|
||||
state = ensureWorkbenchTab(state, "topic-b");
|
||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = setWorkbenchLayout(state, "topic-a", "main-stack");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toEqual({
|
||||
paneKeys: ["topic-a", "topic-c"],
|
||||
activePaneKey: "topic-c",
|
||||
layout: "main-stack",
|
||||
});
|
||||
expect(workbenchTab(state, "topic-b")).toEqual({
|
||||
paneKeys: ["topic-b"],
|
||||
activePaneKey: "topic-b",
|
||||
layout: "columns",
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses without reordering and promotes only when asked", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
|
||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = focusWorkbenchPane(state, "topic-a", "topic-b");
|
||||
|
||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
|
||||
"topic-a",
|
||||
"topic-b",
|
||||
"topic-c",
|
||||
]);
|
||||
|
||||
state = promoteWorkbenchPane(state, "topic-a", "topic-b");
|
||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
||||
paneKeys: ["topic-b", "topic-a", "topic-c"],
|
||||
activePaneKey: "topic-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("detaches child panes, keeps the root, and chooses the adjacent focus", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
|
||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = focusWorkbenchPane(state, "topic-a", "topic-b");
|
||||
state = detachWorkbenchPane(state, "topic-a", "topic-b");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
||||
paneKeys: ["topic-a", "topic-c"],
|
||||
activePaneKey: "topic-c",
|
||||
});
|
||||
|
||||
state = detachWorkbenchPane(state, "topic-a", "topic-c");
|
||||
state = detachWorkbenchPane(state, "topic-a", "topic-a");
|
||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual(["topic-a"]);
|
||||
});
|
||||
|
||||
it("moves a pane between tabs and can reattach a one-pane tab", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
||||
state = ensureWorkbenchTab(state, "topic-b");
|
||||
state = attachWorkbenchPane(state, "topic-b", "pane-a");
|
||||
|
||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
||||
paneKeys: ["topic-a"],
|
||||
activePaneKey: "topic-a",
|
||||
});
|
||||
expect(workbenchTab(state, "topic-b")).toMatchObject({
|
||||
paneKeys: ["topic-b", "pane-a"],
|
||||
activePaneKey: "pane-a",
|
||||
});
|
||||
|
||||
state = ensureWorkbenchTab(state, "topic-c");
|
||||
state = attachWorkbenchPane(state, "topic-b", "topic-c");
|
||||
expect(state.tabs["topic-c"]).toBeUndefined();
|
||||
expect(workbenchTab(state, "topic-b").paneKeys).toEqual([
|
||||
"topic-b",
|
||||
"pane-a",
|
||||
"topic-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not collapse a multi-pane tab into another tab", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
||||
state = ensureWorkbenchTab(state, "topic-b");
|
||||
|
||||
expect(attachWorkbenchPane(state, "topic-b", "topic-a")).toBe(state);
|
||||
});
|
||||
|
||||
it("caps every tab at four panes", () => {
|
||||
let state = EMPTY_WORKBENCH_STATE;
|
||||
for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) {
|
||||
state = addWorkbenchPane(state, "topic-a", `pane-${index}`);
|
||||
}
|
||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
|
||||
"topic-a",
|
||||
"pane-1",
|
||||
"pane-2",
|
||||
"pane-3",
|
||||
]);
|
||||
|
||||
const beforeAttach = state;
|
||||
state = attachWorkbenchPane(state, "topic-a", "standalone");
|
||||
expect(state).toBe(beforeAttach);
|
||||
});
|
||||
|
||||
it("identifies only sessions attached beneath another topic", () => {
|
||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
||||
state = addWorkbenchPane(state, "topic-b", "pane-b");
|
||||
|
||||
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-a", "pane-b"]));
|
||||
|
||||
state = detachWorkbenchPane(state, "topic-a", "pane-a");
|
||||
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-b"]));
|
||||
});
|
||||
|
||||
it("repairs persisted state and removes deleted sessions", () => {
|
||||
const parsed = parseWorkbenchState(JSON.stringify({
|
||||
version: 2,
|
||||
tabs: {
|
||||
"topic-a": {
|
||||
paneKeys: ["topic-a", "topic-b", "topic-b", 9],
|
||||
activePaneKey: "missing",
|
||||
layout: "unknown",
|
||||
},
|
||||
deleted: {
|
||||
paneKeys: ["deleted"],
|
||||
activePaneKey: "deleted",
|
||||
layout: "grid",
|
||||
},
|
||||
},
|
||||
}));
|
||||
const reconciled = reconcileWorkbench(parsed, new Set(["topic-a"]));
|
||||
|
||||
expect(reconciled).toEqual({
|
||||
version: 2,
|
||||
tabs: {
|
||||
"topic-a": {
|
||||
paneKeys: ["topic-a"],
|
||||
activePaneKey: "topic-a",
|
||||
layout: "columns",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parseWorkbenchState(JSON.stringify({ version: 1, tabs: {} })))
|
||||
.toEqual(EMPTY_WORKBENCH_STATE);
|
||||
expect(parseWorkbenchState("not-json")).toEqual(EMPTY_WORKBENCH_STATE);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user